Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7be6f64596 | ||
|
|
0bab7f05b9 | ||
|
|
dc898ce2af | ||
|
|
e5c9ca1a7d | ||
|
|
21a0d560de | ||
|
|
2941121f4b | ||
|
|
6c75ff345b | ||
|
|
d5e25244ee | ||
|
|
4095455e66 | ||
|
|
3f95763ca6 | ||
|
|
72ec3cbb97 | ||
|
|
a8bca8c316 | ||
|
|
8b75166dbe | ||
|
|
d9e03d9de3 | ||
|
|
491546c7f4 | ||
|
|
91b21a4a36 | ||
|
|
eab11db766 | ||
|
|
0c64bdcaf9 | ||
|
|
f0d7a07ed6 | ||
|
|
2aceb99948 | ||
|
|
c48294b0ca | ||
|
|
747c2b9105 | ||
|
|
724e68b38d | ||
|
|
7d664bd1de | ||
|
|
93732ee563 | ||
|
|
5293bb18c5 | ||
|
|
14ac73028e | ||
|
|
b5a88ee5a2 | ||
|
|
e81500f809 | ||
|
|
37805fe3ed | ||
|
|
7578e49573 | ||
|
|
564d97b692 | ||
|
|
99599458ff | ||
|
|
191e92ad62 | ||
|
|
f405e71d7d | ||
|
|
0924062ced | ||
|
|
8683a450a7 | ||
|
|
9f8e3c73d9 | ||
|
|
423cf1f998 | ||
|
|
c0dc1bfcc4 | ||
|
|
46d6531cf5 | ||
|
|
3ed2d957e5 | ||
|
|
5770c40d89 | ||
|
|
5e2c452753 | ||
|
|
f3f966f5b7 | ||
|
|
38c838d232 | ||
|
|
7eb734d86f | ||
|
|
b49599150c | ||
|
|
331db58705 | ||
|
|
62e20de69c | ||
|
|
480d24384b | ||
|
|
c2ff0f714a | ||
|
|
fe2affc72f | ||
|
|
be0326c862 |
+76
-1
@@ -1,6 +1,11 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The coupling is a SET, not a global switch.
|
||||
//
|
||||
@@ -61,3 +66,73 @@ func TestLinkedAmpsNeedsTwo(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Two combined amplifiers must be commanded AT THE SAME TIME, not one after the
|
||||
// other.
|
||||
//
|
||||
// Sequentially, the second was commanded only once the first had answered — and
|
||||
// an SPE answers over its own link, in its own time. The combiner heard power
|
||||
// appear on one input before the other and beeped about it, on every OFF and
|
||||
// every ON. This is what an operator hears, so it is worth a test that would
|
||||
// hear it too.
|
||||
func TestLinkedAmpCommandsLeaveTogether(t *testing.T) {
|
||||
a := &App{}
|
||||
const slow = 150 * time.Millisecond
|
||||
|
||||
var mu sync.Mutex
|
||||
starts := map[string]time.Time{}
|
||||
err := a.ampFanOut([]string{"one", "two"}, func(id string) error {
|
||||
mu.Lock()
|
||||
starts[id] = time.Now()
|
||||
mu.Unlock()
|
||||
time.Sleep(slow) // an amplifier taking its time to answer
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("fan-out: %v", err)
|
||||
}
|
||||
if len(starts) != 2 {
|
||||
t.Fatalf("%d amplifiers were commanded, want both", len(starts))
|
||||
}
|
||||
// Both goroutines wait on one channel and are released by closing it, so the
|
||||
// gap is scheduling noise. Sequential execution would put a full command
|
||||
// between them.
|
||||
gap := starts["one"].Sub(starts["two"])
|
||||
if gap < 0 {
|
||||
gap = -gap
|
||||
}
|
||||
if gap > slow/3 {
|
||||
t.Errorf("the two amplifiers were commanded %v apart — the combiner hears that as one input arriving late", gap)
|
||||
}
|
||||
}
|
||||
|
||||
// The amplifier the operator clicked comes first, and its failure is the one
|
||||
// reported: "the amp I pressed did not respond" beats the same message about
|
||||
// its silent partner.
|
||||
func TestLinkedAmpErrorNamesTheOneClicked(t *testing.T) {
|
||||
a := &App{}
|
||||
clicked := errors.New("the one clicked")
|
||||
other := errors.New("the other one")
|
||||
err := a.ampFanOut([]string{"clicked", "other"}, func(id string) error {
|
||||
if id == "clicked" {
|
||||
return clicked
|
||||
}
|
||||
return other
|
||||
})
|
||||
if !errors.Is(err, clicked) {
|
||||
t.Errorf("fan-out reported %v, want the amplifier the operator pressed", err)
|
||||
}
|
||||
}
|
||||
|
||||
// One amplifier is the ordinary case and must not change: run inline, no
|
||||
// goroutine, no barrier, and the error straight back.
|
||||
func TestSingleAmpRunsInline(t *testing.T) {
|
||||
a := &App{}
|
||||
boom := errors.New("not running")
|
||||
if err := a.ampFanOut([]string{"solo"}, func(string) error { return boom }); !errors.Is(err, boom) {
|
||||
t.Errorf("a single amplifier reported %v, want the error itself", err)
|
||||
}
|
||||
if err := a.ampFanOut(nil, func(string) error { return boom }); err != nil {
|
||||
t.Errorf("an empty group reported %v, want nothing to do", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,20 +52,23 @@ import (
|
||||
"hamlog/internal/powergenius"
|
||||
"hamlog/internal/profile"
|
||||
"hamlog/internal/pskr"
|
||||
"hamlog/internal/psu"
|
||||
"hamlog/internal/qslcard"
|
||||
"hamlog/internal/qso"
|
||||
"hamlog/internal/relaydev"
|
||||
"hamlog/internal/rigctld"
|
||||
"hamlog/internal/rotator/dcu1"
|
||||
"hamlog/internal/rotator/spid"
|
||||
"hamlog/internal/rotator/gs232"
|
||||
"hamlog/internal/rotator/pst"
|
||||
"hamlog/internal/rotator/spid"
|
||||
"hamlog/internal/rotgenius"
|
||||
"hamlog/internal/scp"
|
||||
"hamlog/internal/settings"
|
||||
"hamlog/internal/solar"
|
||||
"hamlog/internal/spe"
|
||||
"hamlog/internal/steppir"
|
||||
"hamlog/internal/syncfolder"
|
||||
"hamlog/internal/tciserver"
|
||||
"hamlog/internal/tunergenius"
|
||||
"hamlog/internal/uls"
|
||||
"hamlog/internal/ultrabeam"
|
||||
@@ -118,6 +121,8 @@ const (
|
||||
keyCATDigitalDefault = "cat.digital_default" // mode to use when CAT reports DATA
|
||||
keyCATShareEnabled = "cat.share.enabled" // expose CAT to other programs (Hamlib NET rigctl)
|
||||
keyCATSharePort = "cat.share.port" // TCP port for that server (rigctld default 4532)
|
||||
keyCATShareProto = "cat.share.proto" // which sharing server runs: "rigctl" (Hamlib NET) or "tci"
|
||||
keyCATShareTCIPort = "cat.share.tci_port" // WebSocket port for the TCI server (TCI default 40001)
|
||||
keyCATXieguPort = "cat.xiegu.port" // Xiegu CI-V serial port (G90/X6100…)
|
||||
keyCATXieguBaud = "cat.xiegu.baud" // Xiegu CI-V baud (G90 default 19200)
|
||||
keyCATXieguAddr = "cat.xiegu.addr" // Xiegu CI-V address (factory 0x70)
|
||||
@@ -455,8 +460,13 @@ type CATSettings struct {
|
||||
PollMs int `json:"poll_ms"` // poll interval in ms (default 250)
|
||||
DelayMs int `json:"delay_ms"` // pause between commands (default 0)
|
||||
DigitalDefault string `json:"digital_default"` // when CAT says DATA, surface this mode (FT8/FT4/RTTY/…)
|
||||
ShareEnabled bool `json:"share_enabled"` // serve CAT to other programs (Hamlib NET rigctl)
|
||||
SharePort int `json:"share_port"` // TCP port for it (default 4532)
|
||||
ShareEnabled bool `json:"share_enabled"` // serve CAT to other programs
|
||||
SharePort int `json:"share_port"` // TCP port for the rigctl server (default 4532)
|
||||
// ShareProto picks WHICH server runs — "rigctl" or "tci". One or the other,
|
||||
// not both: they are two ways of asking the same radio the same questions,
|
||||
// and a second listener is only a second thing to go wrong.
|
||||
ShareProto string `json:"share_proto"`
|
||||
ShareTCIPort int `json:"share_tci_port"` // WebSocket port for the TCI server (default 40001)
|
||||
// PTT hotkey — a keyboard key that keys the transmitter while OpsLog is
|
||||
// focused (hold-to-talk, or toggle). Uses the configured Audio → PTT method,
|
||||
// falling back to CAT keying when that is VOX/none.
|
||||
@@ -594,8 +604,12 @@ type App struct {
|
||||
// port: without it, choosing native CAT locks WSJT-X and friends out of the
|
||||
// radio entirely. nil when the operator has not enabled sharing.
|
||||
catShare *rigctld.Server
|
||||
dxcc *dxcc.Manager
|
||||
cluster *cluster.Manager
|
||||
// catShareTCI serves the same link to programs built around Expert
|
||||
// Electronics' TCI instead. One or the other runs, never both — they answer
|
||||
// the same questions about the same radio, and nothing speaks both.
|
||||
catShareTCI *tciserver.Server
|
||||
dxcc *dxcc.Manager
|
||||
cluster *cluster.Manager
|
||||
// Cluster spots/lines are processed OFF the socket-read goroutine. Enriching a
|
||||
// spot (DXCC/POTA), emitting it to the UI, running alert rules — which can hit
|
||||
// a remote MySQL via isWorkedBandMode — and mirroring it to the Flex all used
|
||||
@@ -690,6 +704,7 @@ type App struct {
|
||||
motorInhibited atomic.Bool // TX currently inhibited by the motor-antenna watcher
|
||||
antgenius *antgenius.Client // Antenna Genius (4O3A) switch (TCP); nil when disabled
|
||||
tunergenius *tunergenius.Client // Tuner Genius XL (4O3A) ATU (TCP); nil when disabled
|
||||
psu *psu.Client // bench power supply over Modbus RTU (serial); nil when disabled
|
||||
pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled
|
||||
spe *spe.Client // legacy pointer: FIRST enabled SPE amp (kept for the pre-multi bindings)
|
||||
acom *acom.Client // legacy pointer: FIRST enabled ACOM amp
|
||||
@@ -733,6 +748,11 @@ type App struct {
|
||||
confDLCancel context.CancelFunc
|
||||
udpLogMu sync.Mutex // serialises UDP auto-log so concurrent packets can't both pass the dedup check
|
||||
adifMonMu sync.Mutex // guards the ADIF-monitor config (file list + per-file read offsets)
|
||||
syncMu sync.Mutex // serialises folder synchronisation: config, the seq counter, and the append to our own file
|
||||
syncSent int64 // changes written to the folder this session
|
||||
syncReceived int64 // changes taken from the other machines this session
|
||||
syncLast time.Time // last completed pass, for the status panel
|
||||
syncErr string // last folder error, shown in settings — a share that dropped is otherwise invisible
|
||||
relayAutoMu sync.Mutex // serialises relay auto-control evaluation
|
||||
relayAutoLast map[string]bool // deviceID|relay → last applied on/off, so we only switch on a real change
|
||||
relayAutoOn atomic.Bool // cached "auto-control enabled" so the CAT hot path skips work when off
|
||||
@@ -764,6 +784,8 @@ type App struct {
|
||||
liveTableMu sync.Mutex // guards liveTableFor
|
||||
liveTableFor *sql.DB // logbook whose live_status DDL has been ensured (once per connection, not per call)
|
||||
awardSnapMu sync.Mutex // guards the award QSO snapshot
|
||||
awardSnapBuild sync.Mutex // serialises BUILDING it — see awardSnapshot
|
||||
awardSnapCap int // rows the last build produced, the capacity hint for the next
|
||||
awardSnap []qso.QSO // light-scanned + enriched logbook snapshot reused across award computations
|
||||
awardSnapRev string // logbook revision the snapshot was built at ("" = none)
|
||||
awardSnapUsed time.Time // last read — the snapshot is dropped once it goes cold (see awardSnapshotJanitor)
|
||||
@@ -1160,6 +1182,7 @@ func (a *App) startup(ctx context.Context) {
|
||||
a.backfillAwardRefsOnce() // one-time: materialise award_refs for pre-existing QSOs
|
||||
go a.rebuildWorkedIndex() // in-memory worked-index for per-spot alert checks
|
||||
go a.adifMonitorLoop() // watch external ADIF files (fldigi, N1MM…) for new QSOs
|
||||
go a.folderSyncLoop() // one operator, several PCs: read the other machines' change logs
|
||||
a.relayAutoOn.Store(a.GetRelayAuto().Enabled) // prime the relay auto-control hot-path flag
|
||||
|
||||
// cty.dat for offline DXCC / country resolution. Cached on disk; first
|
||||
@@ -1415,6 +1438,8 @@ func (a *App) startup(ctx context.Context) {
|
||||
a.startTunerGenius()
|
||||
// PowerGenius XL amp fan control: connect in the background if enabled.
|
||||
a.startAmps()
|
||||
// Bench power supply (Modbus RTU): connect in the background if enabled.
|
||||
a.startPSU()
|
||||
|
||||
// Autostart: launch the active profile's configured external programs that
|
||||
// aren't already running (WSJT-X, JTAlert, rotator control, …). Background
|
||||
@@ -1678,6 +1703,10 @@ func (a *App) shutdown(ctx context.Context) {
|
||||
a.catShare.Stop()
|
||||
a.catShare = nil
|
||||
}
|
||||
if a.catShareTCI != nil {
|
||||
a.catShareTCI.Stop()
|
||||
a.catShareTCI = nil
|
||||
}
|
||||
if a.cat != nil {
|
||||
a.cat.Stop()
|
||||
}
|
||||
@@ -2641,8 +2670,14 @@ func (a *App) reloadLookupProviders() {
|
||||
fmt.Println("OpsLog: settings load error:", err)
|
||||
return
|
||||
}
|
||||
if days, _ := strconv.Atoi(m[keyCacheTTL]); days > 0 {
|
||||
a.cache.SetTTL(time.Duration(days) * 24 * time.Hour)
|
||||
// An EXPLICIT zero switches the cache off; an ABSENT key leaves the thirty-day
|
||||
// default the cache was built with. The difference matters: every operator who
|
||||
// has never opened this setting has no value stored, and reading that blank as
|
||||
// a zero would silently turn the cache off for all of them.
|
||||
if raw := strings.TrimSpace(m[keyCacheTTL]); raw != "" {
|
||||
if days, err := strconv.Atoi(raw); err == nil && days >= 0 {
|
||||
a.cache.SetTTL(time.Duration(days) * 24 * time.Hour)
|
||||
}
|
||||
}
|
||||
|
||||
build := func(name string) lookup.Provider {
|
||||
@@ -2781,6 +2816,14 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
|
||||
a.maybeAutoSendEQSL(qc)
|
||||
a.maybeSelfSpot(qc)
|
||||
a.publishSoon() // refresh the published web page, debounced
|
||||
// Tell the operator's other PCs. Down here with the rest of the
|
||||
// after-the-fact work because a folder on a network share can block
|
||||
// for seconds, and a contact belongs in the database and on screen
|
||||
// long before another machine needs to hear about it.
|
||||
//
|
||||
// Read back rather than sent from `qc`: award_refs was materialised
|
||||
// a few lines above and is not on the copy taken at insert time.
|
||||
a.syncPublishAsync(syncfolder.OpAdd, id, nil)
|
||||
if a.udp != nil {
|
||||
rec := adif.SingleRecordADIF(qc)
|
||||
a.udp.EmitLoggedADIF(rec)
|
||||
@@ -4344,8 +4387,41 @@ func (a *App) awardSnapshot() ([]qso.QSO, error) {
|
||||
a.awardSnapMu.Unlock()
|
||||
}
|
||||
|
||||
// ONE BUILDER AT A TIME.
|
||||
//
|
||||
// The cache lock above is released before the pull, so every caller that
|
||||
// arrives while the logbook is being read used to miss and start its own.
|
||||
// Opening the Awards panel does that: a field log showed three pulls of the
|
||||
// same 123 615 QSOs within ten seconds, and three copies alive at once took
|
||||
// the heap from 725 MB to 2.5 GB. The work was identical each time.
|
||||
//
|
||||
// Waiting here costs the second and third caller the seconds the first was
|
||||
// going to take anyway — they were already paying that, plus a second and
|
||||
// third trip to the database.
|
||||
a.awardSnapBuild.Lock()
|
||||
defer a.awardSnapBuild.Unlock()
|
||||
// Re-check: whoever held the build lock has just finished, and their result
|
||||
// is what we came for.
|
||||
if revErr == nil {
|
||||
a.awardSnapMu.Lock()
|
||||
if a.awardSnap != nil && a.awardSnapRev == rev {
|
||||
qs := a.awardSnap
|
||||
a.awardSnapUsed = time.Now()
|
||||
a.awardSnapMu.Unlock()
|
||||
return qs, nil
|
||||
}
|
||||
a.awardSnapMu.Unlock()
|
||||
}
|
||||
|
||||
t0 := time.Now()
|
||||
var all []qso.QSO
|
||||
// Sized from the last build. Growing a slice to 123 000 structs by doubling
|
||||
// copies the whole thing a dozen times and holds the old and the new array
|
||||
// together at every step — on the biggest object OpsLog keeps, that transient
|
||||
// is worth avoiding.
|
||||
a.awardSnapMu.Lock()
|
||||
hint := a.awardSnapCap
|
||||
a.awardSnapMu.Unlock()
|
||||
all := make([]qso.QSO, 0, hint)
|
||||
if err := a.qso.IterateForAwards(a.ctx, func(q qso.QSO) error {
|
||||
a.enrichQSOForAwards(&q)
|
||||
all = append(all, q)
|
||||
@@ -4361,13 +4437,14 @@ func (a *App) awardSnapshot() ([]qso.QSO, error) {
|
||||
applog.Printf("awardSnapshot: pulled %d qsos from logbook in %v (rev=%s) — go heap now %d MB",
|
||||
len(all), time.Since(t0).Round(time.Millisecond), rev, ms.HeapAlloc/(1024*1024))
|
||||
|
||||
a.awardSnapMu.Lock()
|
||||
a.awardSnapCap = len(all) // next build starts the right size
|
||||
if revErr == nil {
|
||||
a.awardSnapMu.Lock()
|
||||
a.awardSnap = all
|
||||
a.awardSnapRev = rev
|
||||
a.awardSnapUsed = time.Now()
|
||||
a.awardSnapMu.Unlock()
|
||||
}
|
||||
a.awardSnapMu.Unlock()
|
||||
return all, nil
|
||||
}
|
||||
|
||||
@@ -5163,6 +5240,37 @@ func (a *App) SaveAwardReference(code string, ref awardref.Ref) error {
|
||||
}
|
||||
a.markAwardEdited(code)
|
||||
a.mirrorAwards()
|
||||
// A reference's name is what the award column SHOWS for awards displaying by
|
||||
// name, and its pattern is part of what matches at all — so editing one
|
||||
// changes rows in the log, exactly as deleting or replacing the list does.
|
||||
// Those already recomputed; this did not, and left the grid showing the old
|
||||
// label until something else happened to trigger a pass.
|
||||
a.recomputeAwardRefsAsync()
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenameAwardReference changes a reference's code on an award the operator
|
||||
// already runs.
|
||||
//
|
||||
// The one field the editor could not touch, and the one that was wrong: WAJA
|
||||
// shipped numbered by the Japanese state rather than by the JARL. Correcting it
|
||||
// meant deleting the whole list and importing another — throwing away anything
|
||||
// the operator had adjusted in it.
|
||||
func (a *App) RenameAwardReference(code, oldRef, newRef string) error {
|
||||
if a.awardRefs == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
if err := a.awardRefs.Rename(a.ctx, code, oldRef, newRef); err != nil {
|
||||
return err
|
||||
}
|
||||
a.markAwardEdited(code)
|
||||
a.mirrorAwards()
|
||||
// The materialised award columns hold a LABEL computed from the definition —
|
||||
// the reference code for most awards, the name for those displaying by name.
|
||||
// A renumbered reference changes the first kind, so the log is recomputed
|
||||
// exactly as it is for every other reference-list change.
|
||||
a.recomputeAwardRefsAsync()
|
||||
applog.Printf("awards: %s reference %s renumbered to %s", code, oldRef, newRef)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5974,6 +6082,7 @@ func (a *App) UpdateQSO(q qso.QSO) error {
|
||||
if err == nil {
|
||||
a.invalidateAwardStats()
|
||||
a.materializeAwardRefs(q) // fields may have changed → refresh award_refs
|
||||
a.syncPublishAsync(syncfolder.OpUpdate, q.ID, nil)
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -6067,6 +6176,7 @@ func (a *App) DeleteQSO(id int64) error {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
a.deleteRemoteCopies([]int64{id})
|
||||
a.syncPublishDeletes([]int64{id})
|
||||
return a.qso.Delete(a.ctx, id)
|
||||
}
|
||||
|
||||
@@ -6137,6 +6247,7 @@ func (a *App) DeleteQSOs(ids []int64) (int64, error) {
|
||||
return 0, fmt.Errorf("db not initialized")
|
||||
}
|
||||
a.deleteRemoteCopies(ids)
|
||||
a.syncPublishDeletes(ids)
|
||||
return a.qso.DeleteMany(a.ctx, ids)
|
||||
}
|
||||
|
||||
@@ -7087,8 +7198,14 @@ func (a *App) SaveCabrilloFile() (string, error) {
|
||||
// LookupCallsign returns the cached or freshly-fetched info for a callsign.
|
||||
// Errors are returned as-is to the frontend; ErrNotFound surfaces as
|
||||
// "callsign not found".
|
||||
func (a *App) LookupCallsign(callsign string) (lookup.Result, error) {
|
||||
return a.lookupCallsign(callsign, false)
|
||||
// qsoDate is the entry form's date as "YYYY-MM-DD" (or empty for now). It is
|
||||
// what the ClubLog exception is resolved AGAINST: an exception has a validity
|
||||
// window, and a DXpedition's window closes. Entering 3Y0K by hand months later,
|
||||
// with the activation's own date in the form, resolved the exception at TODAY's
|
||||
// date, found none, and fell back to cty.dat — Antarctica instead of Bouvet
|
||||
// Island. A backdated QSO must be enriched as of when it happened.
|
||||
func (a *App) LookupCallsign(callsign string, qsoDate string) (lookup.Result, error) {
|
||||
return a.lookupCallsign(callsign, false, qsoDate)
|
||||
}
|
||||
|
||||
// LookupCallsignFresh is the same, but SKIPS the cache and refreshes it.
|
||||
@@ -7099,11 +7216,11 @@ func (a *App) LookupCallsign(callsign string) (lookup.Result, error) {
|
||||
// subscription went on getting the thin free-account record, and deleting the
|
||||
// cached row by hand was the only way out. A deliberate click must reach the
|
||||
// provider and overwrite what was stored.
|
||||
func (a *App) LookupCallsignFresh(callsign string) (lookup.Result, error) {
|
||||
return a.lookupCallsign(callsign, true)
|
||||
func (a *App) LookupCallsignFresh(callsign string, qsoDate string) (lookup.Result, error) {
|
||||
return a.lookupCallsign(callsign, true, qsoDate)
|
||||
}
|
||||
|
||||
func (a *App) lookupCallsign(callsign string, force bool) (lookup.Result, error) {
|
||||
func (a *App) lookupCallsign(callsign string, force bool, qsoDate string) (lookup.Result, error) {
|
||||
if a.lookup == nil {
|
||||
return lookup.Result{}, fmt.Errorf("lookup not initialized")
|
||||
}
|
||||
@@ -7148,10 +7265,15 @@ func (a *App) lookupCallsign(callsign string, force bool) (lookup.Result, error)
|
||||
r.ImageURL = ""
|
||||
}
|
||||
}
|
||||
// ClubLog exception override (live entry → today's date): for an active
|
||||
// DXpedition the entered call gets the right entity/zones immediately.
|
||||
// ClubLog exception override, resolved AT THE QSO'S DATE.
|
||||
//
|
||||
// An exception carries a validity window and a DXpedition's window closes.
|
||||
// Resolving at today's date is right for a contact happening now and wrong
|
||||
// for one being entered afterwards: 3Y0K typed months later, with the
|
||||
// activation's date in the form, found no live exception and fell back to
|
||||
// cty.dat — Antarctica, where the log says Bouvet Island.
|
||||
if a.clublogCtyEnabled() && a.clublog != nil {
|
||||
if e, ok := a.clublog.Resolve(callsign, time.Now().UTC()); ok {
|
||||
if e, ok := a.clublog.Resolve(callsign, lookupWhen(qsoDate)); ok {
|
||||
r.Country = titleEntity(e.Entity)
|
||||
if e.Cont != "" {
|
||||
r.Continent = e.Cont
|
||||
@@ -7168,6 +7290,19 @@ func (a *App) lookupCallsign(callsign string, force bool) (lookup.Result, error)
|
||||
}
|
||||
}
|
||||
a.enrichFromULS(&r, callsign)
|
||||
// An entity that IS a single island group answers for itself. QRZ carries
|
||||
// <iota> only for the operators who filled it in, and most have not; but a
|
||||
// station in Ascension is on AF-003 whatever its callbook record says, and
|
||||
// the entity is known for every callsign from cty.dat alone.
|
||||
//
|
||||
// So this reaches the operator with no QRZ subscription, and the station
|
||||
// that has never touched a callbook — and it is right when the two
|
||||
// disagree only in the sense that the callbook wins: filled ONLY when the
|
||||
// callbook gave nothing, because an operator who typed their own reference
|
||||
// knows something a table cannot (an IOTA-heavy entity, a rare island).
|
||||
if r.IOTA == "" && r.DXCC != 0 {
|
||||
r.IOTA = awardref.IOTAForDXCC(r.DXCC)
|
||||
}
|
||||
// Custom outbound rows bound to a lookup. After the enrichment, so a
|
||||
// template sees the same grid and county the entry panel is about to show —
|
||||
// two answers for one callsign is how a rotator ends up pointed elsewhere
|
||||
@@ -7269,9 +7404,13 @@ func (a *App) GetLookupSettings() (LookupSettings, error) {
|
||||
if err != nil {
|
||||
return LookupSettings{}, err
|
||||
}
|
||||
ttl, _ := strconv.Atoi(m[keyCacheTTL])
|
||||
if ttl <= 0 {
|
||||
ttl = 30
|
||||
// Same rule as reloadLookupProviders: blank means "never set" and gets the
|
||||
// default, while a stored zero is the operator asking for no cache at all.
|
||||
ttl := 30
|
||||
if raw := strings.TrimSpace(m[keyCacheTTL]); raw != "" {
|
||||
if n, err := strconv.Atoi(raw); err == nil && n >= 0 {
|
||||
ttl = n
|
||||
}
|
||||
}
|
||||
return LookupSettings{
|
||||
QRZUser: m[keyQRZUser],
|
||||
@@ -7291,8 +7430,9 @@ func (a *App) SaveLookupSettings(s LookupSettings) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
if s.CacheTTLDays <= 0 {
|
||||
s.CacheTTLDays = 30
|
||||
// Zero is kept — it means no cache. Only a negative number is nonsense.
|
||||
if s.CacheTTLDays < 0 {
|
||||
s.CacheTTLDays = 0
|
||||
}
|
||||
// Reject a primary == failsafe routing combo — would just hit the same
|
||||
// provider twice. Frontend should prevent this but defend in depth.
|
||||
@@ -7367,7 +7507,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
||||
if a.settings == nil {
|
||||
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
|
||||
}
|
||||
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATXieguPTTLine, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATYaesuLowLines, keyCATKenwoodLowLines, keyCATKenwoodDataMode, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPttHotkeyEnabled, keyCATPttHotkey, keyCATPttHotkeyToggle, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort)
|
||||
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATXieguPTTLine, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATYaesuLowLines, keyCATKenwoodLowLines, keyCATKenwoodDataMode, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPttHotkeyEnabled, keyCATPttHotkey, keyCATPttHotkeyToggle, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort, keyCATShareProto, keyCATShareTCIPort)
|
||||
if err != nil {
|
||||
return CATSettings{}, err
|
||||
}
|
||||
@@ -7409,6 +7549,8 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
||||
DigitalDefault: m[keyCATDigitalDefault],
|
||||
ShareEnabled: m[keyCATShareEnabled] == "1",
|
||||
SharePort: 4532,
|
||||
ShareProto: "rigctl",
|
||||
ShareTCIPort: tciserver.DefaultPort,
|
||||
}
|
||||
if n, _ := strconv.Atoi(m[keyCATFlexPort]); n > 0 && n <= 65535 {
|
||||
out.FlexPort = n
|
||||
@@ -7422,6 +7564,12 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
||||
if n, _ := strconv.Atoi(m[keyCATSharePort]); n > 0 && n <= 65535 {
|
||||
out.SharePort = n
|
||||
}
|
||||
if p := strings.ToLower(strings.TrimSpace(m[keyCATShareProto])); p == "tci" {
|
||||
out.ShareProto = p
|
||||
}
|
||||
if n, _ := strconv.Atoi(m[keyCATShareTCIPort]); n > 0 && n <= 65535 {
|
||||
out.ShareTCIPort = n
|
||||
}
|
||||
if n, _ := strconv.Atoi(m[keyCATXieguBaud]); n > 0 {
|
||||
out.XieguBaud = n
|
||||
}
|
||||
@@ -7479,6 +7627,12 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
||||
if s.SharePort <= 0 || s.SharePort > 65535 {
|
||||
s.SharePort = 4532
|
||||
}
|
||||
if s.ShareProto != "tci" {
|
||||
s.ShareProto = "rigctl"
|
||||
}
|
||||
if s.ShareTCIPort <= 0 || s.ShareTCIPort > 65535 {
|
||||
s.ShareTCIPort = tciserver.DefaultPort
|
||||
}
|
||||
if s.XieguBaud <= 0 {
|
||||
s.XieguBaud = 19200
|
||||
}
|
||||
@@ -7579,6 +7733,8 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
||||
keyCATDigitalDefault: strings.ToUpper(strings.TrimSpace(s.DigitalDefault)),
|
||||
keyCATShareEnabled: shareEnabled,
|
||||
keyCATSharePort: strconv.Itoa(s.SharePort),
|
||||
keyCATShareProto: s.ShareProto,
|
||||
keyCATShareTCIPort: strconv.Itoa(s.ShareTCIPort),
|
||||
} {
|
||||
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||
return err
|
||||
@@ -8771,7 +8927,11 @@ func (a *App) NetDeactivate(id int64) (int64, error) {
|
||||
|
||||
const (
|
||||
defaultEmailSubject = "Our QSO recording — {CALL}"
|
||||
defaultEmailBody = "Hi,\n\nGreat to work you! Please find attached the audio recording of our QSO.\n\n{DATE} · {BAND} · {MODE}\n\n73,\n{MYCALL}"
|
||||
// Same credit line as the QSL e-mail, and for the same reason it lives in the
|
||||
// template rather than being appended at send time: it is a default, so an
|
||||
// operator deletes it once, and one who already saved their own body never
|
||||
// sees it — a stored template is returned verbatim.
|
||||
defaultEmailBody = "Hi,\n\nGreat to work you! Please find attached the audio recording of our QSO.\n\n{DATE} · {BAND} · {MODE}\n\n73,\n{MYCALL}\n\n" + qslCredit
|
||||
)
|
||||
|
||||
// EmailSettings is the user's SMTP config + auto-send + message templates.
|
||||
@@ -10216,6 +10376,15 @@ func (a *App) TestCloudlogUpload() (string, error) {
|
||||
// ── QSL Manager (manual upload) ────────────────────────────────────────
|
||||
|
||||
// uploadColumnFor maps a service id to its QSO sent-status column.
|
||||
// manualUploadPace is the shortest gap between two consecutive single-QSO
|
||||
// uploads in a bulk "Send to …" run, for the services with no batch endpoint
|
||||
// (QRZ.com, HRDLog). Selecting twenty-five thousand QSOs in the QSL Manager and
|
||||
// firing them off as fast as the link allows is exactly the traffic a logbook
|
||||
// service reads as a robot rather than an operator — Club Log threatens to block
|
||||
// an IP for it. The gap is free in practice: a round trip to either already
|
||||
// takes longer than it.
|
||||
const manualUploadPace = 200 * time.Millisecond
|
||||
|
||||
func uploadColumnFor(service string) string {
|
||||
switch extsvc.Service(service) {
|
||||
case extsvc.ServiceQRZ:
|
||||
@@ -10310,10 +10479,13 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if svc == extsvc.ServiceClublog || svc == extsvc.ServiceHRDLog {
|
||||
} else if svc == extsvc.ServiceClublog || svc == extsvc.ServiceHRDLog || svc == extsvc.ServiceEQSL {
|
||||
statusCol, dateCol := "clublog_qso_upload_status", "clublog_qso_upload_date"
|
||||
if svc == extsvc.ServiceHRDLog {
|
||||
switch svc {
|
||||
case extsvc.ServiceHRDLog:
|
||||
statusCol, dateCol = "hrdlog_qso_upload_status", "hrdlog_qso_upload_date"
|
||||
case extsvc.ServiceEQSL:
|
||||
statusCol, dateCol = "eqsl_sent", "eqsl_sent_date"
|
||||
}
|
||||
type item struct {
|
||||
id int64
|
||||
@@ -10377,10 +10549,62 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
|
||||
applog.Printf("extsvc: Club Log batch FAILED (%s) — QSOs: %s", msg, strings.Join(who, ", "))
|
||||
}
|
||||
}
|
||||
} else if svc == extsvc.ServiceEQSL {
|
||||
// eQSL's ImportADIF.cfm is a file importer — it answers "X out of Y
|
||||
// records added" — so send a whole chunk per request instead of one
|
||||
// request per contact. eQSL asks that an upload stay under about a
|
||||
// thousand records; 100 keeps a single refused record from taking the
|
||||
// rest of the chunk with it.
|
||||
const chunk = 100
|
||||
emit(fmt.Sprintf("eQSL: uploading %d QSO(s) in batches of %d…", len(items), chunk))
|
||||
for start := 0; start < len(items); start += chunk {
|
||||
end := start + chunk
|
||||
if end > len(items) {
|
||||
end = len(items)
|
||||
}
|
||||
batch := items[start:end]
|
||||
recs := make([]string, len(batch))
|
||||
batchIDs := make([]int64, len(batch))
|
||||
for i, it := range batch {
|
||||
recs[i] = it.rec
|
||||
batchIDs[i] = it.id
|
||||
}
|
||||
res, err := extsvc.UploadEQSLBatch(ctx, nil, cfg.EQSL.Username, cfg.EQSL.Password, cfg.EQSL.QTHNickname, recs)
|
||||
if err == nil && res.OK {
|
||||
if merr := a.qso.MarkUploadedBatch(ctx, statusCol, dateCol, date, batchIDs); merr != nil {
|
||||
applog.Printf("extsvc: eQSL batch mark: %v", merr)
|
||||
}
|
||||
uploaded += len(batch)
|
||||
// eQSL took the file but left records out. It never says WHICH
|
||||
// — normally they are QSOs it already holds — so quote its own
|
||||
// count rather than claim a clean run.
|
||||
if res.Ignored {
|
||||
emit(fmt.Sprintf("eQSL: %d/%d uploaded — %s", end, len(items), res.Message))
|
||||
} else {
|
||||
emit(fmt.Sprintf("eQSL: %d/%d uploaded", end, len(items)))
|
||||
}
|
||||
} else {
|
||||
msg := res.Message
|
||||
if err != nil {
|
||||
msg = err.Error()
|
||||
}
|
||||
// Name the QSOs in the failing batch, same as Club Log: a
|
||||
// per-record rejection is otherwise impossible to locate.
|
||||
who := make([]string, 0, len(batch))
|
||||
for _, it := range batch {
|
||||
who = append(who, fmt.Sprintf("%s#%d", it.call, it.id))
|
||||
}
|
||||
emit(fmt.Sprintf("eQSL: batch of %d FAILED: %s", len(batch), msg))
|
||||
applog.Printf("extsvc: eQSL batch FAILED (%s) — QSOs: %s", msg, strings.Join(who, ", "))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// HRDLog's NewEntry.aspx inserts only the FIRST record of a multi-
|
||||
// record ADIF, so upload ONE record per request. The DB stays cheap:
|
||||
// bulk fetch above + the marks flushed in batches (not one per QSO).
|
||||
// Paced: HRDLog is the one service here with no way to batch, and a
|
||||
// few thousand requests as fast as the link allows is what a logbook
|
||||
// reads as a robot.
|
||||
emit(fmt.Sprintf("HRDLog: uploading %d QSO(s) (one request each)…", len(items)))
|
||||
var doneIDs []int64
|
||||
flush := func() {
|
||||
@@ -10393,6 +10617,9 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
|
||||
doneIDs = doneIDs[:0]
|
||||
}
|
||||
for i, it := range items {
|
||||
if i > 0 {
|
||||
time.Sleep(manualUploadPace)
|
||||
}
|
||||
res, err := extsvc.UploadHRDLog(ctx, nil, cfg.HRDLog.Callsign, cfg.HRDLog.Code, it.rec)
|
||||
if err == nil && res.OK {
|
||||
doneIDs = append(doneIDs, it.id)
|
||||
@@ -10414,34 +10641,26 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
|
||||
flush()
|
||||
}
|
||||
} else {
|
||||
// QRZ.com: one record per request (its logbook API has no batch upload).
|
||||
for _, id := range ids {
|
||||
// QRZ.com: one record per request (its logbook API has no batch upload),
|
||||
// paced for the same reason as HRDLog above.
|
||||
for i, id := range ids {
|
||||
if i > 0 {
|
||||
time.Sleep(manualUploadPace)
|
||||
}
|
||||
q, gerr := a.qso.GetByID(ctx, id)
|
||||
call := ""
|
||||
if gerr == nil {
|
||||
call = q.Callsign
|
||||
}
|
||||
force := ""
|
||||
if svc == extsvc.ServiceQRZ {
|
||||
force = cfg.QRZ.ForceStationCallsign
|
||||
}
|
||||
rec, ok := a.buildUploadADIF(id, force)
|
||||
// QRZ rewrites STATION_CALLSIGN to the registered call.
|
||||
rec, ok := a.buildUploadADIF(id, cfg.QRZ.ForceStationCallsign)
|
||||
if !ok {
|
||||
emit(call + " — skipped (no record)")
|
||||
continue
|
||||
}
|
||||
var res extsvc.UploadResult
|
||||
var err error
|
||||
switch svc {
|
||||
case extsvc.ServiceQRZ:
|
||||
res, err = extsvc.UploadQRZ(ctx, nil, cfg.QRZ.APIKey, rec)
|
||||
case extsvc.ServiceHRDLog:
|
||||
res, err = extsvc.UploadHRDLog(ctx, nil, cfg.HRDLog.Callsign, cfg.HRDLog.Code, rec)
|
||||
case extsvc.ServiceEQSL:
|
||||
res, err = extsvc.UploadEQSL(ctx, nil, cfg.EQSL.Username, cfg.EQSL.Password, cfg.EQSL.QTHNickname, rec)
|
||||
default:
|
||||
res, err = extsvc.UploadClublog(ctx, nil, cfg.Clublog, rec)
|
||||
}
|
||||
// Only QRZ reaches this branch: LoTW, Club Log, HRDLog and eQSL are
|
||||
// all handled above, and UploadQSOsManual rejects anything else.
|
||||
res, err := extsvc.UploadQRZ(ctx, nil, cfg.QRZ.APIKey, rec)
|
||||
if err == nil && res.OK {
|
||||
a.markExtUploaded(svc, id, "")
|
||||
uploaded++
|
||||
@@ -10549,6 +10768,66 @@ func (a *App) applyULSCounty(q *qso.QSO) {
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateQSOsCountyFromULS re-derives the county of the selected US contacts from
|
||||
// the offline ULS database, and OVERWRITES what is there.
|
||||
//
|
||||
// That is the difference from applyULSCounty beside it, and it is deliberate.
|
||||
// The automatic pass fills blanks only, because a value the operator or QRZ
|
||||
// supplied is usually better than one derived from a ZIP code. This one is asked
|
||||
// for by hand, on a chosen set of rows, precisely BECAUSE the stored county is
|
||||
// believed wrong — the Connecticut planning regions are the case that prompted
|
||||
// it: every CT contact logged before that correction holds a county the state
|
||||
// abolished in 2022, and no amount of filling blanks reaches them.
|
||||
//
|
||||
// Only US entities are touched, only callsigns the database knows, and only when
|
||||
// the answer actually differs — so re-running it on a mixed selection is safe
|
||||
// and the count reported is the number of counties that really changed.
|
||||
func (a *App) UpdateQSOsCountyFromULS(ids []int64) (int, error) {
|
||||
if a.qso == nil {
|
||||
return 0, fmt.Errorf("db not initialized")
|
||||
}
|
||||
if a.uls == nil || a.uls.Count() == 0 {
|
||||
return 0, fmt.Errorf("the US county database has not been downloaded yet — Settings › Awards › US counties")
|
||||
}
|
||||
changed := 0
|
||||
for _, id := range ids {
|
||||
q, err := a.qso.GetByID(a.ctx, id)
|
||||
if err != nil || q.DXCC == nil {
|
||||
continue
|
||||
}
|
||||
switch *q.DXCC {
|
||||
case 291, 110, 6: // United States, Hawaii, Alaska
|
||||
default:
|
||||
continue
|
||||
}
|
||||
loc, ok := a.uls.Resolve(q.Callsign)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
cnty := loc.CNTY()
|
||||
if cnty == "" {
|
||||
continue
|
||||
}
|
||||
if q.County == cnty && (loc.State == "" || q.State == loc.State) {
|
||||
continue
|
||||
}
|
||||
q.County = cnty
|
||||
if loc.State != "" {
|
||||
q.State = loc.State
|
||||
}
|
||||
if a.qso.Update(a.ctx, q) == nil {
|
||||
changed++
|
||||
}
|
||||
}
|
||||
if changed > 0 {
|
||||
// The county IS the reference for CQ USA-CA and the state for WAS, so the
|
||||
// stored award refs are stale the moment it changes.
|
||||
a.invalidateAwardStats()
|
||||
a.materializeAwardRefsForIDs(ids)
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
// ULSStatusResult reports whether the county database is loaded, and how fresh.
|
||||
type ULSStatusResult struct {
|
||||
Count int `json:"count"`
|
||||
@@ -14120,8 +14399,9 @@ func (a *App) ActivateProfile(id int64) error {
|
||||
|
||||
// reloadAfterProfileSwitch re-applies all settings-derived state for the newly
|
||||
// active profile: lookup providers, upload-service accounts, CAT connection,
|
||||
// and the QSO recorder (audio devices). The Winkeyer stays as-is (the operator
|
||||
// connects it explicitly). The frontend reloads its panels via profile:changed.
|
||||
// the station hardware, and the QSO recorder (audio devices). The Winkeyer
|
||||
// stays as-is (the operator connects it explicitly). The frontend reloads its
|
||||
// panels via profile:changed.
|
||||
func (a *App) reloadAfterProfileSwitch() {
|
||||
a.reloadLookupProviders()
|
||||
if a.extsvc != nil {
|
||||
@@ -14131,6 +14411,22 @@ func (a *App) reloadAfterProfileSwitch() {
|
||||
// runs from ActivateProfile, a click, and a rig that is slow to release would
|
||||
// otherwise freeze the switch.
|
||||
a.restartAsync("cat", a.reloadCAT)
|
||||
// Every device below is configured PER PROFILE, and none of them used to
|
||||
// follow the profile: they were built once at startup and again only when
|
||||
// Settings was saved. An operator with an SPE on COM9 for HF and another on
|
||||
// COM10 for 6 m — one amplifier per profile, which is the whole point of
|
||||
// having two — switched profile and stayed connected to the previous port,
|
||||
// then had to open Settings and press Save to get the right one. Save is not
|
||||
// a connect button; switching profile is what asks for this hardware.
|
||||
//
|
||||
// Unconditional, exactly as a save is: the alternative is comparing every
|
||||
// device's configuration between profiles, and a needless reconnect on a
|
||||
// profile switch costs a second, while a missed one costs the amplifier.
|
||||
a.restartAsync("amp", a.startAmps)
|
||||
a.restartAsync("antenna", a.startUltrabeam)
|
||||
a.restartAsync("antgenius", a.startAntGenius)
|
||||
a.restartAsync("tuner", a.startTunerGenius)
|
||||
a.restartAsync("psu", a.startPSU)
|
||||
a.startQSORecorderIfEnabled()
|
||||
}
|
||||
|
||||
@@ -14703,6 +14999,13 @@ type StationDevice struct {
|
||||
OffURLs []string `json:"off_urls,omitempty"`
|
||||
OnPat string `json:"on_pattern,omitempty"` // fallback, {relay} substituted
|
||||
OffPat string `json:"off_pattern,omitempty"`
|
||||
// InsecureTLS accepts an HTTPS certificate that cannot be verified — which
|
||||
// is the only kind a relay board on the LAN can present, having signed it
|
||||
// itself. Off by default, because the other HTTPS case is the opposite one:
|
||||
// a board reached from outside through a proxy with a real certificate,
|
||||
// where verification is what stands between an antenna switch and the
|
||||
// internet.
|
||||
InsecureTLS bool `json:"insecure_tls,omitempty"`
|
||||
}
|
||||
|
||||
// deviceRelayCount is the relay count for a configured device — fixed by type,
|
||||
@@ -14757,6 +15060,13 @@ func buildDeviceDriver(d StationDevice) relaydev.Device {
|
||||
case "usbrelay":
|
||||
// Host carries the COM port (e.g. "COM5"); CH340/LCUS "A0" serial protocol.
|
||||
return relaydev.NewSerialRelay(d.Host, deviceRelayCount(d))
|
||||
case "httpgen":
|
||||
// The whole board lives in its URLs — Host is not used at all, which is
|
||||
// why it may be left empty. Leaving this case out is what made the
|
||||
// generic board fall through to the WebSwitch driver below: it answered
|
||||
// the WebSwitch's own address, never sent one configured URL, and
|
||||
// reported itself offline so every relay button stayed greyed out.
|
||||
return relaydev.NewHTTPGeneric(d.OnURLs, d.OffURLs, d.OnPat, d.OffPat, d.User, d.Pass, deviceRelayCount(d), d.Labels, d.InsecureTLS)
|
||||
default:
|
||||
return relaydev.NewWebswitch(d.Host)
|
||||
}
|
||||
@@ -14765,7 +15075,23 @@ func buildDeviceDriver(d StationDevice) relaydev.Device {
|
||||
// deviceKey is the config signature that, when unchanged, lets us reuse a device's
|
||||
// open driver (and its OS handle) instead of rebuilding it every poll.
|
||||
func deviceKey(d StationDevice) string {
|
||||
return fmt.Sprintf("%s|%s|%s|%s|%d", d.Type, d.Host, d.User, d.Pass, deviceRelayCount(d))
|
||||
k := fmt.Sprintf("%s|%s|%s|%s|%d", d.Type, d.Host, d.User, d.Pass, deviceRelayCount(d))
|
||||
if d.Type == "httpgen" {
|
||||
// The generic board's entire configuration is its URLs, and none of it is
|
||||
// in the signature above. Correcting a typo in one of them would have
|
||||
// handed back the cached driver still holding the wrong address, so the
|
||||
// fix appeared to do nothing until OpsLog was restarted.
|
||||
k += "|" + d.OnPat + "|" + d.OffPat +
|
||||
"|" + strings.Join(d.OnURLs, "\x1f") + "|" + strings.Join(d.OffURLs, "\x1f") +
|
||||
// The labels are part of the wire format here: {value} sends them.
|
||||
// Renaming a relay re-addresses it, and the cached driver would keep
|
||||
// commanding the old name.
|
||||
"|" + strings.Join(d.Labels, "\x1f") +
|
||||
// Ticking the box has to rebuild the driver: the cached one holds the
|
||||
// verifying client and would go on refusing the certificate.
|
||||
fmt.Sprintf("|%t", d.InsecureTLS)
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
// driverFor returns the cached, still-open driver for a device, building it once
|
||||
@@ -15864,13 +16190,24 @@ func (a *App) restartMotorFollow(s UltrabeamSettings) {
|
||||
close(a.ubFollowStop)
|
||||
a.ubFollowStop = nil
|
||||
}
|
||||
if !s.Follow || a.motorAnt == nil {
|
||||
applog.Printf("ultrabeam: follow loop stopped")
|
||||
// Say WHICH of the two reasons it is. "follow loop stopped" covered both, and
|
||||
// at startup — where nothing was running to stop — it read as a fault. An
|
||||
// operator whose antenna sat at 21050 while the rig worked 21074 sent a log
|
||||
// that said the antenna had started, answered every poll, and had its follow
|
||||
// loop "stopped": three lines that together looked like a link going dead,
|
||||
// when tracking was simply switched off and the antenna was waiting to be
|
||||
// tuned by hand.
|
||||
if a.motorAnt == nil {
|
||||
applog.Printf("antenna: tracking not started — no antenna connected")
|
||||
return
|
||||
}
|
||||
if !s.Follow {
|
||||
applog.Printf("antenna: %s connected, but TRACKING IS OFF in Settings — it will not follow the rig, and only moves when you tune it by hand", s.Type)
|
||||
return
|
||||
}
|
||||
stop := make(chan struct{})
|
||||
a.ubFollowStop = stop
|
||||
applog.Printf("ultrabeam: follow loop restarting — covered bands %v, mode %s, step %d kHz", s.Bands, normMotorTrackMode(s.TrackMode), s.StepKHz)
|
||||
applog.Printf("antenna: %s tracking the rig — covered bands %v, mode %s, step %d kHz", s.Type, s.Bands, normMotorTrackMode(s.TrackMode), s.StepKHz)
|
||||
go a.ultrabeamFollowLoop(a.motorAnt, s.TrackMode, s.StepKHz, s.Bands, stop)
|
||||
}
|
||||
|
||||
@@ -16472,13 +16809,55 @@ func (a *App) AmpOperate(id string, on bool) error {
|
||||
// Fan out here rather than in the UI: the card and the docked widget both
|
||||
// call this, and a coupling implemented in one of them would be missing from
|
||||
// the other — which on a combiner means one amplifier keyed and one not.
|
||||
var firstErr error
|
||||
for _, tid := range a.ampTargets(id, a.GetLinkedAmps()) {
|
||||
if err := a.ampOperateOne(tid, on); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
return a.ampFanOut(a.ampTargets(id, a.GetLinkedAmps()), func(tid string) error {
|
||||
return a.ampOperateOne(tid, on)
|
||||
})
|
||||
}
|
||||
|
||||
// ampFanOut runs one command against every amplifier AT THE SAME TIME.
|
||||
//
|
||||
// Sequentially, the second amplifier of a combined pair was commanded only once
|
||||
// the first had answered — and an SPE answers over its own link, in its own
|
||||
// time. The combiner heard power appear on one input before the other and
|
||||
// complained about it, on every OFF and every ON.
|
||||
//
|
||||
// So each target gets a goroutine, and they are all parked on the same channel
|
||||
// until every one of them is ready. Closing it releases them together: the
|
||||
// difference between "start one, then start the other" and "both leave at
|
||||
// once". They have separate clients and separate connections, so nothing
|
||||
// downstream re-serialises them.
|
||||
//
|
||||
// A single target — the ordinary case of one amplifier — runs inline. No
|
||||
// goroutine, no barrier, nothing to go wrong for the operators who have one amp.
|
||||
func (a *App) ampFanOut(targets []string, do func(id string) error) error {
|
||||
if len(targets) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(targets) == 1 {
|
||||
return do(targets[0])
|
||||
}
|
||||
errs := make([]error, len(targets))
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
for i, id := range targets {
|
||||
wg.Add(1)
|
||||
go func(i int, id string) {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
errs[i] = do(id)
|
||||
}(i, id)
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
// The amplifier the operator actually clicked is first, and its failure is
|
||||
// the one worth reporting: "the amp I pressed did not respond" beats the
|
||||
// same message about its silent partner.
|
||||
for _, err := range errs {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) ampOperateOne(id string, on bool) error {
|
||||
@@ -16506,15 +16885,11 @@ func (a *App) AmpPower(id string, on bool) (err error) {
|
||||
applog.Printf("amp %s: power %v failed: %v", id, on, err)
|
||||
}
|
||||
}()
|
||||
linked := a.GetLinkedAmps()
|
||||
targets := a.ampTargets(id, linked)
|
||||
var firstErr error
|
||||
for _, tid := range targets {
|
||||
if e := a.ampPowerOne(tid, on, len(targets) > 1); e != nil && firstErr == nil {
|
||||
firstErr = e
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
targets := a.ampTargets(id, a.GetLinkedAmps())
|
||||
multi := len(targets) > 1
|
||||
return a.ampFanOut(targets, func(tid string) error {
|
||||
return a.ampPowerOne(tid, on, multi)
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) ampPowerOne(id string, on, linked bool) error {
|
||||
@@ -16544,12 +16919,31 @@ func (a *App) ampPowerOne(id string, on, linked bool) error {
|
||||
}
|
||||
|
||||
// AmpPowerLevel selects the output power level — SPE only (L/M/H).
|
||||
//
|
||||
// Coupled like ON, OFF and OPERATE, which it was not: two combined amplifiers
|
||||
// left at different power levels feed the combiner unevenly, which is the thing
|
||||
// the coupling exists to prevent. It was simply the command nobody had linked.
|
||||
//
|
||||
// Running the pair concurrently matters more here than anywhere else. Setting a
|
||||
// level is not one command: an SPE has no "set level" at all, so the driver taps
|
||||
// the POWER key and waits for the amp to report the new level before tapping
|
||||
// again — up to three taps, up to two seconds each. One after the other, the two
|
||||
// amplifiers would sit at different levels for as long as six seconds.
|
||||
func (a *App) AmpPowerLevel(id, level string) error {
|
||||
inst := a.ampInstByID(id)
|
||||
if inst == nil || inst.spe == nil {
|
||||
return fmt.Errorf("power level is an SPE feature")
|
||||
}
|
||||
return inst.spe.SetPowerLevel(level)
|
||||
targets := a.ampTargets(id, a.GetLinkedAmps())
|
||||
multi := len(targets) > 1
|
||||
return a.ampFanOut(targets, func(tid string) error {
|
||||
inst := a.ampInstByID(tid)
|
||||
if inst == nil || inst.spe == nil {
|
||||
// A PowerGenius sitting in the group has no L/M/H, and saying so
|
||||
// would make a successful SPE pair look like a failure.
|
||||
if multi {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("power level is an SPE feature")
|
||||
}
|
||||
return inst.spe.SetPowerLevel(level)
|
||||
})
|
||||
}
|
||||
|
||||
// AmpFanMode sets the fan mode — PGXL only (STANDARD/CONTEST/BROADCAST).
|
||||
@@ -17378,16 +17772,26 @@ func (a *App) SendClusterCommand(cmd string) error {
|
||||
if cmd == "" {
|
||||
return fmt.Errorf("empty command")
|
||||
}
|
||||
servers, err := a.listClusterServers()
|
||||
srv, err := a.masterClusterServer()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.cluster.SendCommand(srv.ID, cmd)
|
||||
}
|
||||
|
||||
// masterClusterServer returns the master node — the first ENABLED server in
|
||||
// sort order, which is where commands and spots go.
|
||||
func (a *App) masterClusterServer() (cluster.ServerConfig, error) {
|
||||
servers, err := a.listClusterServers()
|
||||
if err != nil {
|
||||
return cluster.ServerConfig{}, err
|
||||
}
|
||||
for _, s := range servers {
|
||||
if s.Enabled {
|
||||
return a.cluster.SendCommand(s.ID, cmd)
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("no enabled cluster server to send to")
|
||||
return cluster.ServerConfig{}, fmt.Errorf("no enabled cluster server to send to")
|
||||
}
|
||||
|
||||
// SendClusterSpot announces a DX spot on the **master** cluster (first
|
||||
@@ -17409,8 +17813,26 @@ func (a *App) SendClusterSpot(call string, freqKHz float64, comment string) erro
|
||||
if c := strings.TrimSpace(comment); c != "" {
|
||||
cmd += " " + c
|
||||
}
|
||||
if a.cluster == nil {
|
||||
return fmt.Errorf("cluster not initialized")
|
||||
}
|
||||
srv, err := a.masterClusterServer()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
applog.Printf("cluster: send spot — freqKHz=%v → command %q", freqKHz, cmd)
|
||||
return a.SendClusterCommand(cmd)
|
||||
if err := a.cluster.SendCommand(srv.ID, cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
// Show it in OUR OWN spot list straight away. Most nodes never broadcast a
|
||||
// spot back to the station that sent it, so the operator saw nothing appear
|
||||
// and reported the spot as not sent — several times. It goes through the same
|
||||
// queue as a spot off the wire, so it gets the same DXCC/POTA enrichment,
|
||||
// alert evaluation and panadapter mirroring, and the UI de-dupes it against
|
||||
// the node's echo when there is one.
|
||||
sp := cluster.NewLocalSpot(srv, a.resolveClusterLogin(srv.LoginOverride), call, freqKHz, comment)
|
||||
a.enqueueClusterEvent(clusterEvent{spot: &sp})
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetClusterStatus returns a snapshot of every active session. Used by
|
||||
@@ -17983,6 +18405,18 @@ func (r catShareRig) Split() (bool, int64) {
|
||||
return true, st.FreqHz
|
||||
}
|
||||
|
||||
// RxFreq is where we LISTEN. Only the TCI server asks for it: TCI's channel A
|
||||
// is the receive frequency and channel B the transmit one, the opposite way
|
||||
// round from RigState, and a client handed these two the wrong way about would
|
||||
// transmit on the DX's own frequency.
|
||||
func (r catShareRig) RxFreq() int64 {
|
||||
st := r.a.cat.State()
|
||||
if st.Split && st.RxFreqHz > 0 {
|
||||
return st.RxFreqHz
|
||||
}
|
||||
return st.FreqHz
|
||||
}
|
||||
|
||||
func (r catShareRig) SetFreq(hz int64) error { return r.a.cat.SetFrequency(hz) }
|
||||
func (r catShareRig) SetMode(m string) error { return r.a.cat.SetMode(m) }
|
||||
func (r catShareRig) SetPTT(on bool) error { return r.a.cat.SetPTT(on) }
|
||||
@@ -17997,17 +18431,35 @@ func (r catShareRig) SetSplit(on bool, txHz int64) error {
|
||||
|
||||
// reloadCATShare starts, stops or restarts the sharing server to match the
|
||||
// settings. Called from reloadCAT so one "Save & Close" settles both.
|
||||
//
|
||||
// One server or the other, never both. rigctl and TCI are two ways of asking
|
||||
// the same radio the same questions; running both would only double the ways a
|
||||
// port clash or a confused client can go wrong, and no program speaks both.
|
||||
func (a *App) reloadCATShare(s CATSettings) {
|
||||
want := s.Enabled && s.ShareEnabled
|
||||
// Always tear down first: the port may have changed, and a listener bound to
|
||||
// the old one would keep answering while the client is told to use the new.
|
||||
// Always tear down first: the port — or the protocol — may have changed, and
|
||||
// a listener bound to the old one would keep answering while the client is
|
||||
// told to use the new.
|
||||
if a.catShare != nil {
|
||||
a.catShare.Stop()
|
||||
a.catShare = nil
|
||||
}
|
||||
if a.catShareTCI != nil {
|
||||
a.catShareTCI.Stop()
|
||||
a.catShareTCI = nil
|
||||
}
|
||||
if !want {
|
||||
return
|
||||
}
|
||||
if s.ShareProto == "tci" {
|
||||
srv := tciserver.New(s.ShareTCIPort, catShareRig{a: a}, applog.Printf)
|
||||
if err := srv.Start(); err != nil {
|
||||
applog.Printf("cat share: %v", err)
|
||||
return
|
||||
}
|
||||
a.catShareTCI = srv
|
||||
return
|
||||
}
|
||||
srv := rigctld.New(s.SharePort, catShareRig{a: a}, applog.Printf)
|
||||
if err := srv.Start(); err != nil {
|
||||
// The usual cause is another rigctld — or a previous OpsLog — already on
|
||||
@@ -18360,3 +18812,31 @@ func wsjtLoggedQSO(q qso.QSO) udp.LoggedQSO {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// lookupWhen turns the entry form's date into the instant a ClubLog exception
|
||||
// should be resolved at.
|
||||
//
|
||||
// Empty, unparseable, or in the future → now. A date is only trusted to move
|
||||
// the resolution BACKWARDS: a half-typed "2026-0" must not send the lookup to
|
||||
// the year 20, and a mistyped future date must not resolve against an exception
|
||||
// window that has not opened.
|
||||
//
|
||||
// Midday UTC rather than midnight: an exception window given in whole days is
|
||||
// inclusive of its end date, and resolving at 00:00 of that day sits on the
|
||||
// boundary where an off-by-one in either direction changes the answer.
|
||||
func lookupWhen(qsoDate string) time.Time {
|
||||
now := time.Now().UTC()
|
||||
s := strings.TrimSpace(qsoDate)
|
||||
if s == "" {
|
||||
return now
|
||||
}
|
||||
t, err := time.Parse("2006-01-02", s)
|
||||
if err != nil {
|
||||
return now
|
||||
}
|
||||
t = t.Add(12 * time.Hour)
|
||||
if t.After(now) {
|
||||
return now
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
"hamlog/internal/psu"
|
||||
)
|
||||
|
||||
// ── Bench power supply (Modbus RTU) ──────────────────────────────────────────
|
||||
//
|
||||
// A programmable supply feeding the shack, switched on and off from OpsLog so
|
||||
// the station comes up and goes down with the logbook rather than by reaching
|
||||
// behind the desk.
|
||||
//
|
||||
// OpsLog READS the supply's measurements and its set points, and WRITES exactly
|
||||
// one thing: the output on/off. The register map has the voltage and current
|
||||
// set points and the three protection trip levels as writable too, and none of
|
||||
// them belong to a logbook — a wrong value there is 30 V where a radio expected
|
||||
// 13.8. See internal/psu for the map and where it comes from.
|
||||
|
||||
const (
|
||||
keyPSUEnabled = "psu.enabled"
|
||||
keyPSUPort = "psu.com_port"
|
||||
keyPSUBaud = "psu.baud"
|
||||
keyPSUAddress = "psu.address" // Modbus slave address, 1…15 on this family
|
||||
)
|
||||
|
||||
// PSUSettings is the JSON shape for the Hardware → Power supply panel.
|
||||
type PSUSettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
ComPort string `json:"com_port"`
|
||||
Baud int `json:"baud"` // 9600 from the factory
|
||||
Address int `json:"address"` // 1 from the factory
|
||||
}
|
||||
|
||||
// GetPSUSettings returns the persisted supply config.
|
||||
func (a *App) GetPSUSettings() (PSUSettings, error) {
|
||||
out := PSUSettings{Baud: 9600, Address: 1}
|
||||
if a.settings == nil {
|
||||
return out, fmt.Errorf("db not initialized")
|
||||
}
|
||||
m, err := a.settings.GetMany(a.ctx, keyPSUEnabled, keyPSUPort, keyPSUBaud, keyPSUAddress)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.Enabled = m[keyPSUEnabled] == "1"
|
||||
out.ComPort = m[keyPSUPort]
|
||||
if v, e := strconv.Atoi(m[keyPSUBaud]); e == nil && v > 0 {
|
||||
out.Baud = v
|
||||
}
|
||||
if v, e := strconv.Atoi(m[keyPSUAddress]); e == nil && v >= 1 && v <= 250 {
|
||||
out.Address = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SavePSUSettings persists the config and (re)starts or stops the client.
|
||||
func (a *App) SavePSUSettings(s PSUSettings) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
if s.Baud <= 0 {
|
||||
s.Baud = 9600
|
||||
}
|
||||
// The manual gives 1…15 for the address field and 1…250 for the address
|
||||
// SETTING register. Clamped to the wider range and defaulted to 1: an
|
||||
// address of 0 is the Modbus broadcast, which never answers, so accepting it
|
||||
// would give a supply that is present and permanently "not responding".
|
||||
if s.Address < 1 || s.Address > 250 {
|
||||
s.Address = 1
|
||||
}
|
||||
for k, v := range map[string]string{
|
||||
keyPSUEnabled: boolStr(s.Enabled),
|
||||
keyPSUPort: strings.TrimSpace(s.ComPort),
|
||||
keyPSUBaud: strconv.Itoa(s.Baud),
|
||||
keyPSUAddress: strconv.Itoa(s.Address),
|
||||
} {
|
||||
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
a.restartAsync("psu", a.startPSU)
|
||||
return nil
|
||||
}
|
||||
|
||||
// startPSU stops any running client and starts a fresh one if the supply is
|
||||
// enabled and has a port. Safe to call repeatedly (startup, settings save,
|
||||
// profile switch).
|
||||
func (a *App) startPSU() {
|
||||
if a.psu != nil {
|
||||
go a.psu.Stop()
|
||||
a.psu = nil
|
||||
}
|
||||
s, err := a.GetPSUSettings()
|
||||
if err != nil {
|
||||
applog.Printf("psu: not started — settings unavailable: %v", err)
|
||||
return
|
||||
}
|
||||
if !s.Enabled {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(s.ComPort) == "" {
|
||||
applog.Printf("psu: not started — no serial port configured")
|
||||
return
|
||||
}
|
||||
applog.Printf("psu: starting on %s @ %d baud, Modbus address %d", s.ComPort, s.Baud, s.Address)
|
||||
a.psu = psu.New(psu.Config{ComPort: s.ComPort, Baud: s.Baud, Address: byte(s.Address)})
|
||||
_ = a.psu.Start()
|
||||
}
|
||||
|
||||
// GetPSUStatus returns the supply's last polled state for the UI.
|
||||
func (a *App) GetPSUStatus() psu.Status {
|
||||
if a.psu == nil {
|
||||
return psu.Status{}
|
||||
}
|
||||
return a.psu.GetStatus()
|
||||
}
|
||||
|
||||
// SetPSUOutput switches the supply's output. The only write OpsLog makes to it.
|
||||
func (a *App) SetPSUOutput(on bool) error {
|
||||
if a.psu == nil {
|
||||
return fmt.Errorf("the power supply is not enabled in Settings")
|
||||
}
|
||||
return a.psu.SetOutput(on)
|
||||
}
|
||||
+12
-1
@@ -45,9 +45,20 @@ const appQSLCardSentField = "APP_OPSLOG_QSL_SENT"
|
||||
// (please send one). Independent of ADIF qsl_rcvd, like the sent field.
|
||||
const appQSLCardRcvdField = "APP_OPSLOG_QSL_RCVD"
|
||||
|
||||
// qslCredit closes the default QSL e-mail: who made the card, and where the
|
||||
// recipient can get the same program.
|
||||
//
|
||||
// A DEFAULT, not a signature. It lives in the body template like every other
|
||||
// line, so an operator who does not want it deletes it once and it is gone —
|
||||
// and one who has already written their own body never sees it appear, because
|
||||
// a stored template is returned verbatim and the default is only the fallback.
|
||||
// Appending it at send time instead would have made it unremovable, which is
|
||||
// not a thing to do to someone's outgoing mail.
|
||||
const qslCredit = "--\nDesigned & sent by OpsLog — " + releasesPageURL
|
||||
|
||||
const (
|
||||
defaultQSLEmailSubject = "eQSL — {CALL} de {MYCALL}"
|
||||
defaultQSLEmailBody = "Hi,\n\nThank you for our QSO! Please find attached your eQSL card.\n\n{DATE} · {BAND} · {MODE}\n\n73,\n{MYCALL}"
|
||||
defaultQSLEmailBody = "Hi,\n\nThank you for our QSO! Please find attached your eQSL card.\n\n{DATE} · {BAND} · {MODE}\n\n73,\n{MYCALL}\n\n" + qslCredit
|
||||
)
|
||||
|
||||
// qslDir is the root of all designer artifacts: templates/<id>/ and outbox/.
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The award snapshot must be built ONCE however many callers ask at once.
|
||||
//
|
||||
// The cache lock is released before the logbook is read, so every caller that
|
||||
// arrives during a build used to miss the cache and start its own. Opening the
|
||||
// Awards panel does exactly that: a field log showed three pulls of the same
|
||||
// 123 615 QSOs inside ten seconds, and three copies alive together took the Go
|
||||
// heap from 725 MB to 2.5 GB.
|
||||
//
|
||||
// This models the same shape — a cheap cache check, a slow build, a shared
|
||||
// result — against the pattern awardSnapshot now uses, so the invariant is
|
||||
// pinned without needing a logbook.
|
||||
func TestSnapshotBuildsOncePerRevision(t *testing.T) {
|
||||
var (
|
||||
cacheMu sync.Mutex
|
||||
buildMu sync.Mutex
|
||||
cached []int
|
||||
rev = "r1"
|
||||
gotRev string
|
||||
builds int
|
||||
)
|
||||
|
||||
get := func() []int {
|
||||
cacheMu.Lock()
|
||||
if cached != nil && gotRev == rev {
|
||||
defer cacheMu.Unlock()
|
||||
return cached
|
||||
}
|
||||
cacheMu.Unlock()
|
||||
|
||||
buildMu.Lock()
|
||||
defer buildMu.Unlock()
|
||||
// Re-check: whoever held the build lock has just finished.
|
||||
cacheMu.Lock()
|
||||
if cached != nil && gotRev == rev {
|
||||
defer cacheMu.Unlock()
|
||||
return cached
|
||||
}
|
||||
cacheMu.Unlock()
|
||||
|
||||
time.Sleep(50 * time.Millisecond) // the logbook read
|
||||
out := []int{1, 2, 3}
|
||||
|
||||
cacheMu.Lock()
|
||||
builds++
|
||||
cached, gotRev = out, rev
|
||||
cacheMu.Unlock()
|
||||
return out
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
results := make([][]int, 8)
|
||||
for i := range results {
|
||||
wg.Add(1)
|
||||
go func(i int) { defer wg.Done(); results[i] = get() }(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if builds != 1 {
|
||||
t.Errorf("%d builds for one revision — each concurrent caller pulled the whole logbook again", builds)
|
||||
}
|
||||
for i, r := range results {
|
||||
if len(r) != 3 {
|
||||
t.Errorf("caller %d got %v", i, r)
|
||||
}
|
||||
}
|
||||
|
||||
// A new revision must rebuild: the guard is against duplicate work, not
|
||||
// against a logbook that changed.
|
||||
cacheMu.Lock()
|
||||
rev = "r2"
|
||||
cacheMu.Unlock()
|
||||
get()
|
||||
if builds != 2 {
|
||||
t.Errorf("builds = %d after the revision moved, want 2 — a changed logbook must be re-read", builds)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// A band change now has two sources: the rig, and the entry strip on a station
|
||||
// whose rig OpsLog does not control. They share one memory of the last band, so
|
||||
// a station that has both commands its antenna switch once per QSY rather than
|
||||
// twice — the entry selector pushes a QSY, the rig reports the same band back,
|
||||
// and only the first of the two is a change.
|
||||
func TestBandChangeIsNotedOncePerBand(t *testing.T) {
|
||||
lastTriggerBandMu.Lock()
|
||||
lastTriggerBand = ""
|
||||
lastTriggerBandMu.Unlock()
|
||||
|
||||
if b, changed := noteBandChange("20m"); !changed || b != "20m" {
|
||||
t.Fatalf("first 20m = (%q,%v), want (\"20m\",true)", b, changed)
|
||||
}
|
||||
// The same band from the other source — the rig echoing the QSY back.
|
||||
if _, changed := noteBandChange("20M"); changed {
|
||||
t.Error("the rig echoing the band back counted as a second change — the switch would be commanded twice")
|
||||
}
|
||||
if _, changed := noteBandChange(" 20m "); changed {
|
||||
t.Error("whitespace made the same band look new")
|
||||
}
|
||||
if b, changed := noteBandChange("40m"); !changed || b != "40m" {
|
||||
t.Errorf("40m = (%q,%v), want (\"40m\",true)", b, changed)
|
||||
}
|
||||
// An unknown band must not be recorded, or the next real one would look
|
||||
// unchanged against it.
|
||||
if _, changed := noteBandChange(""); changed {
|
||||
t.Error("an empty band was treated as a change")
|
||||
}
|
||||
if _, changed := noteBandChange("40m"); changed {
|
||||
t.Error("the empty band overwrote the last one")
|
||||
}
|
||||
}
|
||||
+110
@@ -1,4 +1,114 @@
|
||||
[
|
||||
{
|
||||
"version": "0.25.8",
|
||||
"date": "",
|
||||
"en": [
|
||||
"An entity that is a single island group now fills the IOTA reference on its own — no callbook subscription needed.",
|
||||
"CAT sharing can now speak TCI instead of Hamlib, split included, so a TCI-only program reaches whatever radio you are on.",
|
||||
"Lookup cache: a TTL of 0 switches it off, so a callbook record you are correcting is re-read every time.",
|
||||
"TCI radios: when the rig forbids transmitting, PTT says so instead of doing nothing silently.",
|
||||
"WAJA carried Japan’s civil prefecture numbers instead of the JARL’s: 35 of the 47 references are renumbered.",
|
||||
"Award references can be renumbered in the editor — the number was the one field it would not let you correct.",
|
||||
"The compass fills the moment Station Control opens, instead of waiting out the rest of a polling interval.",
|
||||
"Combined amplifiers: the power level (L/M/H) is coupled too, and both amps are commanded at once so the combiner stops beeping.",
|
||||
"Generic HTTP relay: an https:// board can be accepted with its own self-signed certificate, per board.",
|
||||
"Club Log: the on-close upload now goes out as one batch — sending hundreds of contacts one at a time got operators blocked.",
|
||||
"eQSL: uploads go out in batches of 100 too, and QRZ.com and HRDLog — which have no batch upload — are spaced out instead.",
|
||||
"A spot you send now shows in your own spot list — most nodes never echo it back, so it looked like nothing had gone out.",
|
||||
"Icom with JTDX in Fake It split: a lost PTT acknowledgement is sent again instead of failing, which made JTDX drop the rig.",
|
||||
"A cluster whose only greeting is “login:” and which then asks for a password now connects — both prompts were being missed."
|
||||
],
|
||||
"fr": [
|
||||
"Une entité qui est un seul groupe d’îles remplit désormais la référence IOTA toute seule, sans abonnement callbook.",
|
||||
"Le partage CAT peut désormais parler TCI au lieu de Hamlib, split compris : un logiciel TCI atteint la radio, quelle qu’elle soit.",
|
||||
"Cache des recherches : un TTL à 0 le désactive, pour relire à chaque fois une fiche callbook en cours de correction.",
|
||||
"Radios TCI : quand la radio interdit l’émission, le PTT le dit au lieu de ne rien faire en silence.",
|
||||
"WAJA portait les numéros civils des préfectures japonaises et non ceux de la JARL : 35 des 47 références sont renumérotées.",
|
||||
"Les références d’un diplôme se renumérotent dans l’éditeur : le numéro était le seul champ qu’il refusait de corriger.",
|
||||
"La boussole se remplit dès l’ouverture de Station Control, au lieu d’attendre la fin d’un intervalle d’interrogation.",
|
||||
"Amplis combinés : le niveau de puissance (L/M/H) est couplé lui aussi, et les deux amplis sont commandés en même temps — fini le bip du combineur.",
|
||||
"Relais HTTP générique : une carte en https:// peut être acceptée avec son certificat auto-signé, carte par carte.",
|
||||
"Club Log : l’envoi à la fermeture part désormais en un lot — envoyer des centaines de contacts un par un faisait bloquer l’opérateur.",
|
||||
"eQSL : les envois partent aussi par lots de 100, et QRZ.com et HRDLog — qui n’ont pas d’envoi groupé — sont espacés à la place.",
|
||||
"Un spot que tu envoies apparaît maintenant dans ta liste : la plupart des nœuds ne le renvoient pas, il semblait n’être jamais parti.",
|
||||
"Icom avec JTDX en split Fake It : un accusé de réception PTT perdu est renvoyé au lieu d’échouer — JTDX lâchait le poste.",
|
||||
"Un cluster dont tout l’accueil est « login: » puis qui réclame un mot de passe se connecte : les deux invites étaient ignorées."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.25.7",
|
||||
"date": "",
|
||||
"en": [
|
||||
"Opening the Awards panel no longer pulls the whole logbook several times at once — a large log briefly took gigabytes of memory.",
|
||||
"Awards: the Missing refs button now only appears where it means something — a worldwide award like POTA could only ever answer “nothing found”.",
|
||||
"Worked before: a prefixed call like ZA/OE8NDR matched every other visitor to that country instead of that one operator.",
|
||||
"French: seventeen strings were still in English, the whole update panel among them, plus Spot lifetime and Chase new grids.",
|
||||
"Selecting a QSO shows the entity the QSO records, not one re-derived from its callsign — a 3Y0K contact logged as Bouvet showed the Antarctica matrix.",
|
||||
"Back-entering a QSO resolves the ClubLog exception at the CONTACT’S date, so a DXpedition entered months later gets the entity it had then.",
|
||||
"QRZ.com sends an island reference for an operator on one, and OpsLog read past it — it now fills the IOTA award reference before the QSO is logged.",
|
||||
"Sync across PCs: point every OpsLog at one folder you already synchronise and your contacts follow you between machines.",
|
||||
"TCI: a station spotted by several operators is drawn once on the panorama instead of two or three times a few hertz apart."
|
||||
],
|
||||
"fr": [
|
||||
"Ouvrir le panneau Awards ne tire plus plusieurs fois le journal entier en même temps — un gros log occupait brièvement des gigaoctets de mémoire.",
|
||||
"Awards : le bouton Réf. manquantes n’apparaît plus que là où il a un sens — un diplôme mondial comme POTA ne pouvait répondre que « aucun manque ».",
|
||||
"Déjà contacté : un indicatif préfixé comme ZA/OE8NDR rapprochait tous les autres visiteurs du pays au lieu de ce seul opérateur.",
|
||||
"Français : dix-sept textes étaient restés en anglais, dont tout le panneau de mise à jour, la durée de vie des spots et Chasser les nouveaux locators.",
|
||||
"Sélectionner un QSO affiche l’entité que le QSO enregistre, pas une recalculée depuis l’indicatif — un 3Y0K logué Bouvet montrait la matrice Antarctique.",
|
||||
"Saisir un QSO a posteriori résout l’exception ClubLog à la date DU CONTACT : une DXpedition entrée des mois après retrouve l’entité qu’elle avait alors.",
|
||||
"QRZ.com envoie la référence d’île d’un opérateur sur une île, et OpsLog l’ignorait — elle remplit désormais la référence IOTA avant l’enregistrement du QSO.",
|
||||
"Synchro entre PC : fais pointer chaque OpsLog vers un dossier déjà synchronisé et tes contacts te suivent d’une machine à l’autre.",
|
||||
"TCI : une station spottée par plusieurs opérateurs n’est tracée qu’une fois sur le panorama, au lieu de deux ou trois fois."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.25.6",
|
||||
"date": "",
|
||||
"en": [
|
||||
"Right-click: update the US county of the selected contacts from the ULS database, replacing a county since renamed or abolished.",
|
||||
"A QSO logged from WSJT-X, MSHV or a net now appears in Recent QSOs at once, instead of waiting for a delayed auto-upload to send it.",
|
||||
"New installs: the default QSL and recording e-mails end with a credit line and a link to OpsLog. Part of the template, so delete it if unwanted.",
|
||||
"Relay automatic control and band-change messages now follow the Band selector too, so a station without CAT switches its antenna when you change band.",
|
||||
"Fixed OpsLog re-tuning its own rig from its own radio broadcasts, which dropped the CAT link on every JTDX or WSJT-X “Fake It” transmission.",
|
||||
"New device: a bench power supply on Modbus RTU (BSIDE, Wanptek and kin) — its output switched from Station Control, with volts, amps and watts.",
|
||||
"SPID rotator: a Rot1Prog controller turned nearly a full circle the wrong way for every heading — its commands take three digits, not four.",
|
||||
"The compass sweeps with a turning antenna instead of jumping, and the controller is polled once for the whole app rather than by each panel.",
|
||||
"A rotator test that only reads the heading now says so, instead of naming PstRotator for a move it never commanded."
|
||||
],
|
||||
"fr": [
|
||||
"Clic droit : mettre à jour le comté US des contacts sélectionnés depuis la base ULS, pour remplacer un comté renommé ou supprimé.",
|
||||
"Un QSO logué depuis WSJT-X, MSHV ou un net apparaît aussitôt dans les QSO récents, sans attendre l’envoi d’un upload automatique différé.",
|
||||
"Nouvelles installations : les mails QSL et enregistrement par défaut finissent par une ligne de crédit et un lien vers OpsLog. Dans le modèle, supprimable.",
|
||||
"Le contrôle automatique des relais et les messages de changement de bande suivent aussi le champ Band : une station sans CAT commute enfin son antenne.",
|
||||
"Corrigé : OpsLog réaccordait sa propre radio depuis ses propres diffusions, ce qui coupait le lien CAT à chaque émission JTDX ou WSJT-X en « Fake It ».",
|
||||
"Nouvel appareil : alimentation de laboratoire en Modbus RTU (BSIDE, Wanptek et similaires) — sortie commutée depuis Contrôle station, avec V, A et W.",
|
||||
"Rotator SPID : un contrôleur Rot1Prog tournait presque un tour complet à l’envers pour chaque azimut — ses commandes tiennent trois chiffres, pas quatre.",
|
||||
"Le compas suit l’antenne en rotation au lieu de sauter, et le contrôleur est interrogé une fois pour toute l’application, plus par chaque panneau.",
|
||||
"Un test de rotator qui ne fait que lire l’azimut le dit, au lieu de citer PstRotator pour un mouvement jamais commandé."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.25.5",
|
||||
"date": "",
|
||||
"en": [
|
||||
"DX cluster: when spots arrive and every one is filtered out, the panel says so, names the filters doing it and offers to clear them — it used to say “waiting for spots” beside a counter reading 76 live.",
|
||||
"DX cluster: the log times the connection and the first spot, so a slow first launch can be told apart from a quiet node.",
|
||||
"Generic HTTP relay: its URLs were never actually sent — fixed. {value} sends the relay's label, {relay-1} counts from zero, no host needed.",
|
||||
"Switching profile now reconnects the amplifier, antenna, Antenna Genius and tuner — they stayed on the previous profile's ports until you saved Settings.",
|
||||
"SteppIR: one missed reply used to wedge the link — the antenna stopped responding, with nothing at all in the log. It now recovers on its own.",
|
||||
"The log now says when a motorized antenna is connected but tracking is off, instead of a line that read like a fault.",
|
||||
"DXHunter spot clicks tune the rig again: the frequency it sends is read in whatever unit it uses, not always as MHz."
|
||||
],
|
||||
"fr": [
|
||||
"Cluster DX : quand des spots arrivent et que tout est filtré, le panneau le dit, nomme les filtres responsables et propose de les effacer — il affichait « en attente de spots » à côté d’un compteur à 76 en direct.",
|
||||
"Cluster DX : le journal chronomètre la connexion et le premier spot, pour distinguer un premier lancement lent d’un nœud silencieux.",
|
||||
"Relais HTTP générique : ses URL n’étaient jamais envoyées — corrigé. {value} envoie le libellé du relais, {relay-1} compte de zéro, hôte inutile.",
|
||||
"Changer de profil reconnecte l’ampli, l’antenne, l’Antenna Genius et le tuner — ils restaient sur les ports du profil précédent jusqu’à un Enregistrer.",
|
||||
"SteppIR : une seule réponse manquée bloquait la liaison — l’antenne ne répondait plus, sans rien dans le journal. Elle se rétablit maintenant seule.",
|
||||
"Le journal indique désormais qu’une antenne motorisée est connectée mais que le suivi est désactivé, au lieu d’une ligne qui ressemblait à une panne.",
|
||||
"Les clics de spot DXHunter accordent à nouveau le rig : la fréquence envoyée est lue dans son unité réelle, plus toujours en MHz."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.25.4",
|
||||
"date": "",
|
||||
|
||||
+178
-36
@@ -11,17 +11,17 @@ import {
|
||||
SaveCabrilloFile, ExportCabrillo, ExportCabrilloFiltered, ExportCabrilloSelected,
|
||||
ContestDupe,
|
||||
GetQSO, UpdateQSO, DeleteQSO, DeleteQSOs, DeleteAllQSO,
|
||||
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
|
||||
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UpdateQSOsCountyFromULS, ULSStatus, UploadQSOsManual, SendQSORecordingEmail,
|
||||
LookupCallsign, GetStationSettings, GetListsSettings,
|
||||
GetStartupStatus, CheckForUpdate, DownloadAndApplyUpdate, GetLiveStations, GetWhatsNew, GetChangelog,
|
||||
SMTPConfigured, SendLogToDeveloper,
|
||||
WorkedBefore,
|
||||
SetCompactMode, SetCompactHeight,
|
||||
RotatorGoToPath,
|
||||
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna, FlexApplyBandPower,
|
||||
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, EntryBandChanged, FlexApplyBandAntenna, FlexApplyBandPower,
|
||||
GetSecretStatus, UnlockSecrets,
|
||||
RefreshCtyDat, DownloadAllReferenceLists,
|
||||
RotatorGoTo, RotatorStop, GetRotatorHeading, SetActiveRotor,
|
||||
RotatorGoTo, RotatorStop, SetActiveRotor,
|
||||
GetDBConnectionInfo, GetLogbookRevision,
|
||||
GetUltrabeamStatus, SetUltrabeamDirection, UILog,
|
||||
GetAntGeniusStatus, GetAntGeniusSettings, AntGeniusActivate,
|
||||
@@ -56,7 +56,7 @@ import {
|
||||
QSLViaRepairStatus, RepairQSLVia, DismissQSLViaRepair,
|
||||
} from '../wailsjs/go/main/App';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { applyAwardRefs, parseAwardRefs as parseManualRefs, spotRefList } from '@/lib/awardRefs';
|
||||
import { applyAwardRefs, parseAwardRefs as parseManualRefs, spotRefList , withIOTARef } from '@/lib/awardRefs';
|
||||
import { EventsOn, BrowserOpenURL, WindowMinimise, WindowToggleMaximise, WindowIsMaximised, Quit } from '../wailsjs/runtime/runtime';
|
||||
import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
|
||||
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||
@@ -107,6 +107,7 @@ import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
|
||||
import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
|
||||
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
|
||||
import { RotorCompass } from '@/components/RotorCompass';
|
||||
import { subscribeRotorHeading, pokeRotorHeading } from '@/lib/rotorHeading';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
import { formatDateTimeUTC } from '@/lib/dateFormat';
|
||||
import { titleCase, sentenceCase } from '@/lib/textCase';
|
||||
@@ -730,10 +731,19 @@ export default function App() {
|
||||
function onBandUserChange(v: string) {
|
||||
setBand(v);
|
||||
noteManualEdit();
|
||||
if (catState.enabled && catState.connected && !locks.band && !locks.freq) {
|
||||
// A lock means this value is deliberately decoupled from the rig (logging an
|
||||
// old contact off-frequency) — drive nothing, neither the radio nor an antenna.
|
||||
if (locks.band || locks.freq) return;
|
||||
if (catState.enabled && catState.connected) {
|
||||
const hz = qsyFreqHz(v, mode);
|
||||
if (hz > 0) SetCATFrequency(hz).catch(() => {});
|
||||
return; // the rig reports back, and the relays follow from its state
|
||||
}
|
||||
// No rig to push to, so THIS is the band change. Without it, relay automatic
|
||||
// control and the band-change outbound rows never heard about it — they only
|
||||
// ever listened to the CAT state, which on a station without rig control
|
||||
// never says anything.
|
||||
EntryBandChanged(v).catch(() => {});
|
||||
}
|
||||
function onModeUserChange(v: string) {
|
||||
setMode(v);
|
||||
@@ -1539,6 +1549,41 @@ export default function App() {
|
||||
// Hide spots already worked (exact call worked, or this band+mode slot done).
|
||||
const [clusterHideWorked, setClusterHideWorked] = useState(() => lsBool('opslog.clusterHideWorked', false));
|
||||
|
||||
// Everything currently narrowing the spot list, in words. Shown when spots
|
||||
// arrived and none survived — the operator needs to know WHICH filter to
|
||||
// loosen, and the two locks are the least memorable because they follow the
|
||||
// rig rather than a click.
|
||||
const clusterActiveFilterSummary = useMemo(() => {
|
||||
const on: string[] = [];
|
||||
if (clusterLockBand) on.push(t('clg2.fBandLock'));
|
||||
else if (clusterBands.size > 0) on.push(t('clg2.fBands', { list: [...clusterBands].join(', ') }));
|
||||
if (clusterLockMode) on.push(t('clg2.fModeLock'));
|
||||
else if (clusterModeFilter.size > 0) on.push(t('clg2.fModes', { list: [...clusterModeFilter].join(', ') }));
|
||||
if (clusterStatusFilter.size > 0) on.push(t('clg2.fStatus'));
|
||||
if (clusterHideWorked) on.push(t('clg2.fHideWorked'));
|
||||
if (clusterLotwOnly) on.push(t('clg2.fLotwOnly'));
|
||||
if (clusterSpotterConts.size > 0) on.push(t('clg2.fSpotterCont'));
|
||||
if (clusterFilterSource) on.push(t('clg2.fSource'));
|
||||
if (clusterSearch.trim()) on.push(t('clg2.fSearch', { q: clusterSearch.trim() }));
|
||||
return on.join(' · ');
|
||||
}, [t, clusterLockBand, clusterBands, clusterLockMode, clusterModeFilter, clusterStatusFilter,
|
||||
clusterHideWorked, clusterLotwOnly, clusterSpotterConts, clusterFilterSource, clusterSearch]);
|
||||
|
||||
// Undo every one of them at once. A list of ten switches spread down a panel
|
||||
// is not something to walk back by hand when the answer is "show me anything".
|
||||
const clearClusterFilters = useCallback(() => {
|
||||
setClusterLockBand(false);
|
||||
setClusterBands(new Set());
|
||||
setClusterLockMode(false);
|
||||
setClusterModeFilter(new Set());
|
||||
setClusterStatusFilter(new Set());
|
||||
setClusterHideWorked(false);
|
||||
setClusterLotwOnly(false);
|
||||
setClusterSpotterConts(new Set());
|
||||
setClusterFilterSource('');
|
||||
setClusterSearch('');
|
||||
}, []);
|
||||
|
||||
// Persist every cluster filter selection whenever it changes, so it is still
|
||||
// set after a close/reopen.
|
||||
useEffect(() => {
|
||||
@@ -1735,7 +1780,10 @@ export default function App() {
|
||||
// Stats (F1) matrix whenever the entry form is empty — clicking a past contact
|
||||
// is the natural way to ask "what else have I got with this one?", and until
|
||||
// now the panel just sat blank.
|
||||
const [selQso, setSelQso] = useState<{ call: string; band: string; mode: string } | null>(null);
|
||||
// The selected row's own entity travels with it. Re-deriving it from the
|
||||
// callsign is what showed Antarctica for a 3Y0K contact the log records as
|
||||
// Bouvet Island — see the WorkedBefore call below.
|
||||
const [selQso, setSelQso] = useState<{ call: string; band: string; mode: string; dxcc: number } | null>(null);
|
||||
const [bulkEditIds, setBulkEditIds] = useState<number[]>([]);
|
||||
const [bulkEditOpen, setBulkEditOpen] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
@@ -2192,16 +2240,34 @@ export default function App() {
|
||||
}
|
||||
}, [buildActiveFilter]);
|
||||
|
||||
// Refresh the Recent QSOs grid after external-service uploads stamp the
|
||||
// sent status (auto-upload via extsvc:uploaded, or manual QSL Manager via
|
||||
// qslmgr:done). Debounced so a batch of per-QSO events triggers one reload.
|
||||
// Reload the Recent QSOs grid when the log changes.
|
||||
//
|
||||
// qso:logged is the important one, and it was missing. The entry form calls
|
||||
// refresh() itself after AddQSO, so a QSO TYPED here always appeared — but a
|
||||
// QSO logged from anywhere else (WSJT-X / MSHV over UDP, JTAlert, NET
|
||||
// Control, an offline replay) only reached the grid when something else
|
||||
// happened to reload it, and the something else was the upload: with
|
||||
// auto-upload set to immediate, extsvc:uploaded arrived a second later and
|
||||
// hid the gap entirely.
|
||||
//
|
||||
// Which is why this surfaced as "delayed upload breaks the recent list". It
|
||||
// does not — the grid was waiting on the upload for its news. On a delayed
|
||||
// service the QSO appeared one to two minutes late, and on close-of-session
|
||||
// upload not until the next launch: the corrections that deferring the upload
|
||||
// exists to allow could not be made, because there was nothing to correct on
|
||||
// screen. The list follows the LOG now, and the upload only stamps a status.
|
||||
//
|
||||
// Debounced: qso:logged fires twice per contact (once on insert, once when
|
||||
// the award refs are materialised), and a contest run fires it every few
|
||||
// seconds.
|
||||
useEffect(() => {
|
||||
let t: number | undefined;
|
||||
const ping = () => { if (t) window.clearTimeout(t); t = window.setTimeout(() => { refresh(); }, 400); };
|
||||
const offLogged = EventsOn('qso:logged', ping);
|
||||
const offUploaded = EventsOn('extsvc:uploaded', ping);
|
||||
const offDone = EventsOn('qslmgr:done', ping);
|
||||
const offEqsl = EventsOn('qsl:sent', ping);
|
||||
return () => { offUploaded(); offDone(); offEqsl(); if (t) window.clearTimeout(t); };
|
||||
return () => { offLogged(); offUploaded(); offDone(); offEqsl(); if (t) window.clearTimeout(t); };
|
||||
}, [refresh]);
|
||||
|
||||
// The backend bulk-recomputed the materialised award_refs (an award definition
|
||||
@@ -2328,17 +2394,12 @@ export default function App() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Poll PstRotator for the live antenna heading (status bar). Cheap when the
|
||||
// rotator is disabled (the backend just reads settings and returns).
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const tick = async () => {
|
||||
try { const h: any = await GetRotatorHeading(); if (alive) setRotatorHeading(h); } catch {}
|
||||
};
|
||||
tick();
|
||||
const id = window.setInterval(tick, 3000);
|
||||
return () => { alive = false; window.clearInterval(id); };
|
||||
}, []);
|
||||
// The live antenna heading, from the shared poller in lib/rotorHeading — fast
|
||||
// while the antenna turns, slow while it is parked, and ONE loop however many
|
||||
// panels are watching. This used to be its own interval alongside Station
|
||||
// Control's, so the controller was polled twice over whenever that tab was
|
||||
// open, and every poll opens and closes the port.
|
||||
useEffect(() => subscribeRotorHeading((h) => setRotatorHeading(h as any)), []);
|
||||
|
||||
// Poll the Ultrabeam antenna for its connection + pattern direction.
|
||||
useEffect(() => {
|
||||
@@ -3548,6 +3609,26 @@ export default function App() {
|
||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
finally { setBulkProgress(null); }
|
||||
}
|
||||
// Whether the offline US county database holds anything. It gates the
|
||||
// right-click entry below: an action that can only answer "no database" is
|
||||
// not worth a line in a menu this long. Re-read when a download finishes, so
|
||||
// the entry appears without a restart.
|
||||
const [ulsReady, setUlsReady] = useState(false);
|
||||
useEffect(() => {
|
||||
const read = () => ULSStatus().then((s: any) => setUlsReady((s?.count ?? 0) > 0)).catch(() => {});
|
||||
read();
|
||||
const off = EventsOn('uls:done', () => { read(); });
|
||||
return () => { off(); };
|
||||
}, []);
|
||||
|
||||
// Re-derive the county of the selected US contacts from the offline ULS
|
||||
// database, OVERWRITING what is stored. Offered only once that database has
|
||||
// been downloaded — see ulsReady above.
|
||||
async function bulkUpdateCountyFromULS(ids: number[]) {
|
||||
if (ids.length === 0) return;
|
||||
try { await afterBulkUpdate(await UpdateQSOsCountyFromULS(ids as any), t('qctx.fromUlsLabel')); }
|
||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
async function bulkUpdateFromClublog(ids: number[]) {
|
||||
if (ids.length === 0) return;
|
||||
try { await afterBulkUpdate(await UpdateQSOsFromClublog(ids as any), 'from ClubLog'); }
|
||||
@@ -3674,7 +3755,19 @@ export default function App() {
|
||||
if (!call || callsign.trim()) { setSelWb(null); return; }
|
||||
let dead = false;
|
||||
setSelWbBusy(true);
|
||||
WorkedBefore(call, 0)
|
||||
// The SELECTED QSO's own entity, not one re-derived from its callsign.
|
||||
//
|
||||
// Passing 0 lets the backend resolve the entity from cty.dat and the
|
||||
// ClubLog exceptions AS THEY ARE TODAY, which is right for a live contact
|
||||
// and wrong for one being looked at in the log. A 3Y0K contact recorded as
|
||||
// Bouvet Island showed the Antarctica matrix, and counted five contacts
|
||||
// "with this entity" against eleven with the call — two different entities,
|
||||
// one of them nobody asked about.
|
||||
//
|
||||
// Browsing the log shows what the log says, even where the log is wrong.
|
||||
// Correcting an entity is a deliberate act (right-click → Update from
|
||||
// ClubLog), not something a panel does behind the operator's back.
|
||||
WorkedBefore(call, selQso?.dxcc || 0)
|
||||
.then((w: any) => { if (!dead) setSelWb(w); })
|
||||
.catch(() => { if (!dead) setSelWb(null); })
|
||||
.finally(() => { if (!dead) setSelWbBusy(false); });
|
||||
@@ -3783,7 +3876,11 @@ export default function App() {
|
||||
const gen = lookupGenRef.current; // invalidated by ESC / resetEntry
|
||||
setLookupBusy(true);
|
||||
try {
|
||||
const r = await LookupCallsign(call);
|
||||
// The ENTRY'S date, so a ClubLog exception resolves at the moment the
|
||||
// contact happened. Live, that is now and nothing changes; back-entering
|
||||
// a past QSO, it is what makes 3Y0K come back Bouvet Island instead of
|
||||
// Antarctica — the exception's window had closed by today.
|
||||
const r = await LookupCallsign(call, qsoStartedAt ? qsoStartedAt.toISOString().slice(0, 10) : '');
|
||||
// Discard a STALE result: the operator already moved to another call
|
||||
// (clicked a new spot / typed) OR cleared the entry (ESC) while this lookup
|
||||
// was in flight. Applying it would clobber the current fields and zoom the
|
||||
@@ -3837,6 +3934,24 @@ export default function App() {
|
||||
email: d.email || (r.email ?? ''),
|
||||
web: d.web || (r.web ?? ''),
|
||||
qsl_via: d.qsl_via || (r.qsl_via ?? ''),
|
||||
// An island reference from the callbook becomes an IOTA award reference
|
||||
// on the entry, exactly like one picked by hand.
|
||||
//
|
||||
// QRZ carries <iota> for an operator on an island and OpsLog read past
|
||||
// it. That matters more for IOTA than it would for another award:
|
||||
// unlike POTA there is no live "who is on an island right now" feed
|
||||
// anywhere, so the callbook record is the practical source — and it is
|
||||
// known BEFORE the contact is logged, which is when it is useful.
|
||||
//
|
||||
// Only when the operator has not set one already: a reference typed or
|
||||
// picked by hand outranks a callbook that may be years out of date.
|
||||
//
|
||||
// This belongs HERE, on the provider result, and not in the backfill
|
||||
// from the last QSO where it first went: that runs only when there IS a
|
||||
// previous contact with the call, so the island appeared for stations
|
||||
// already in the log and never for the new one on the island — which is
|
||||
// the entire point of the feature.
|
||||
award_refs: withIOTARef(d.award_refs ?? '', String((r as any)?.iota ?? '')),
|
||||
}));
|
||||
// Backfill anything the provider didn't supply from the last time we worked
|
||||
// this call (call not found on QRZ/HamQTH, or lookup off → cty.dat only).
|
||||
@@ -5217,7 +5332,7 @@ export default function App() {
|
||||
return (
|
||||
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
|
||||
<WorkedBeforeGrid key={`wbg-${activeProfileId ?? 'x'}`} wb={wbWithAwards as any} myGrid={station.my_grid} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
||||
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog}
|
||||
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onUpdateCountyFromULS={ulsReady ? bulkUpdateCountyFromULS : undefined}
|
||||
onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
|
||||
onBulkEdit={openBulkEdit} onExportSelected={exportSelectedADIF} onExportSelectedFields={exportSelectedFields}
|
||||
onExportCabrilloSelected={exportSelectedCabrillo} onDelete={(ids) => setDeletingIds(ids)} />
|
||||
@@ -5247,7 +5362,7 @@ export default function App() {
|
||||
<div className="h-full w-full min-h-0 flex flex-col rounded-lg overflow-hidden border border-border">
|
||||
<NetControlPanel onLogged={refresh} countries={countries} bands={bands} modes={modes}
|
||||
qsoMenuHandlers={{
|
||||
onUpdateFromCty: bulkUpdateFromCty, onUpdateFromQRZ: bulkUpdateFromQRZ, onUpdateFromClublog: bulkUpdateFromClublog,
|
||||
onUpdateFromCty: bulkUpdateFromCty, onUpdateFromQRZ: bulkUpdateFromQRZ, onUpdateFromClublog: bulkUpdateFromClublog, onUpdateCountyFromULS: ulsReady ? bulkUpdateCountyFromULS : undefined,
|
||||
onSendTo: bulkSendTo, onSendRecording: bulkSendRecording, onSendEQSL: (ids: number[]) => setEqslQsoId(ids[0] ?? null),
|
||||
onDelete: (ids: number[]) => setDeletingIds(ids),
|
||||
}} />
|
||||
@@ -5271,7 +5386,7 @@ export default function App() {
|
||||
onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
||||
onUpdateFromCty={bulkUpdateFromCty}
|
||||
onUpdateFromQRZ={bulkUpdateFromQRZ}
|
||||
onUpdateFromClublog={bulkUpdateFromClublog}
|
||||
onUpdateFromClublog={bulkUpdateFromClublog} onUpdateCountyFromULS={ulsReady ? bulkUpdateCountyFromULS : undefined}
|
||||
onSendTo={bulkSendTo}
|
||||
onSendRecording={bulkSendRecording}
|
||||
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
|
||||
@@ -5281,7 +5396,7 @@ export default function App() {
|
||||
onExportFiltered={exportFilteredADIF}
|
||||
onDelete={(ids) => setDeletingIds(ids)}
|
||||
onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }}
|
||||
onRowSelectedQso={(r) => setSelQso(r ? { call: String(r.callsign ?? ""), band: String(r.band ?? ""), mode: String(r.mode ?? "") } : null)}
|
||||
onRowSelectedQso={(r) => setSelQso(r ? { call: String(r.callsign ?? ""), band: String(r.band ?? ""), mode: String(r.mode ?? ""), dxcc: Number(r.dxcc ?? 0) } : null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -5409,7 +5524,7 @@ export default function App() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => RotatorStop().catch((err) => setError(String(err?.message ?? err)))}
|
||||
onClick={() => { pokeRotorHeading(); RotatorStop().catch((err) => setError(String(err?.message ?? err))); }}
|
||||
title="Stop rotation"
|
||||
className="px-1.5 py-0.5 border-l border-info-border text-danger hover:bg-danger-muted hover:text-danger-muted-foreground cursor-pointer transition-colors"
|
||||
>
|
||||
@@ -6199,8 +6314,8 @@ export default function App() {
|
||||
rotorEnabled={rotatorHeading.enabled && rotatorHeading.ok}
|
||||
rotors={(rotatorHeading as any).rotors}
|
||||
activeRotor={(rotatorHeading as any).active}
|
||||
onSelectRotor={(i) => { SetActiveRotor(i).then(() => GetRotatorHeading()).then((h: any) => setRotatorHeading(h)).catch((err) => setError(String(err?.message ?? err))); }}
|
||||
onGoto={(az) => RotatorGoTo(Math.round(az), -1).catch((err) => setError(String(err?.message ?? err)))}
|
||||
onSelectRotor={(i) => { SetActiveRotor(i).then(pokeRotorHeading).catch((err) => setError(String(err?.message ?? err))); }}
|
||||
onGoto={(az) => { RotatorGoTo(Math.round(az), -1).then(pokeRotorHeading).catch((err) => setError(String(err?.message ?? err))); }}
|
||||
onClose={() => { setShowRotor(false); writeUiPref('opslog.showRotor', '0'); }}
|
||||
/>
|
||||
</div>
|
||||
@@ -6587,7 +6702,7 @@ export default function App() {
|
||||
onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
||||
onUpdateFromCty={bulkUpdateFromCty}
|
||||
onUpdateFromQRZ={bulkUpdateFromQRZ}
|
||||
onUpdateFromClublog={bulkUpdateFromClublog}
|
||||
onUpdateFromClublog={bulkUpdateFromClublog} onUpdateCountyFromULS={ulsReady ? bulkUpdateCountyFromULS : undefined}
|
||||
onSendTo={bulkSendTo}
|
||||
onSendRecording={bulkSendRecording}
|
||||
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
|
||||
@@ -6599,7 +6714,7 @@ export default function App() {
|
||||
onExportCabrilloFiltered={exportFilteredCabrillo}
|
||||
onDelete={(ids) => setDeletingIds(ids)}
|
||||
onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }}
|
||||
onRowSelectedQso={(r) => setSelQso(r ? { call: String(r.callsign ?? ""), band: String(r.band ?? ""), mode: String(r.mode ?? "") } : null)}
|
||||
onRowSelectedQso={(r) => setSelQso(r ? { call: String(r.callsign ?? ""), band: String(r.band ?? ""), mode: String(r.mode ?? ""), dxcc: Number(r.dxcc ?? 0) } : null)}
|
||||
/>
|
||||
<div className="px-3 py-1.5 border-t border-border/60 text-[11px] text-muted-foreground flex items-center justify-between gap-3 bg-muted/30">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -6738,14 +6853,41 @@ export default function App() {
|
||||
// pane). All the filter state lives in the right-side panel.
|
||||
const rendered = clusterRenderedRows;
|
||||
if (rendered.length === 0) {
|
||||
const connected = clusterServerStatuses.some((s) => s.state === 'connected');
|
||||
// Spots HAVE arrived and the filters ate every one of them.
|
||||
//
|
||||
// This used to say "Waiting for spots…" regardless, which is a
|
||||
// lie the moment the counter beside it reads 76 live: an
|
||||
// operator reads the two together and goes looking for a
|
||||
// connection fault instead of at the filter panel. The band and
|
||||
// mode locks are the usual culprits and the least visible —
|
||||
// they follow the rig, so nobody remembers switching them on.
|
||||
if (connected && spots.length > 0) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground gap-2 py-12 px-6 text-center">
|
||||
<SlidersHorizontal className="size-10 opacity-30" />
|
||||
<div className="text-sm font-semibold text-foreground/70">
|
||||
{t('clg2.allFiltered', { n: spots.length })}
|
||||
</div>
|
||||
{clusterActiveFilterSummary && (
|
||||
<div className="text-xs max-w-md leading-relaxed">
|
||||
{t('clg2.activeFilters')} <span className="font-medium text-foreground/80">{clusterActiveFilterSummary}</span>
|
||||
</div>
|
||||
)}
|
||||
<Button size="sm" variant="outline" className="mt-1" onClick={clearClusterFilters}>
|
||||
{t('clg2.clearAllFilters')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground gap-2 py-12">
|
||||
<Hash className="size-10 opacity-30" />
|
||||
<div className="text-sm font-semibold text-foreground/70">
|
||||
{clusterServerStatuses.some((s) => s.state === 'connected') ? 'Waiting for spots…' : 'No active connection'}
|
||||
{connected ? 'Waiting for spots…' : 'No active connection'}
|
||||
</div>
|
||||
<div className="text-xs">
|
||||
{clusterServerStatuses.some((s) => s.state === 'connected')
|
||||
{connected
|
||||
? 'Spots will appear as the cluster sends them.'
|
||||
: 'Use Connect all (or configure a cluster in Settings → DX Cluster).'}
|
||||
</div>
|
||||
@@ -6840,7 +6982,7 @@ export default function App() {
|
||||
|
||||
<TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1">
|
||||
<WorkedBeforeGrid key={`wbg-${activeProfileId ?? 'x'}`} wb={wbWithAwards as any} myGrid={station.my_grid} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
||||
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording}
|
||||
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onUpdateCountyFromULS={ulsReady ? bulkUpdateCountyFromULS : undefined} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording}
|
||||
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
|
||||
onBulkEdit={openBulkEdit} onExportSelected={exportSelectedADIF} onExportSelectedFields={exportSelectedFields}
|
||||
onExportCabrilloSelected={exportSelectedCabrillo} onDelete={(ids) => setDeletingIds(ids)} />
|
||||
@@ -6937,7 +7079,7 @@ export default function App() {
|
||||
<TabsContent value="net" className="mt-0 flex flex-col min-h-0 flex-1">
|
||||
<NetControlPanel onLogged={refresh} countries={countries} bands={bands} modes={modes}
|
||||
qsoMenuHandlers={{
|
||||
onUpdateFromCty: bulkUpdateFromCty, onUpdateFromQRZ: bulkUpdateFromQRZ, onUpdateFromClublog: bulkUpdateFromClublog,
|
||||
onUpdateFromCty: bulkUpdateFromCty, onUpdateFromQRZ: bulkUpdateFromQRZ, onUpdateFromClublog: bulkUpdateFromClublog, onUpdateCountyFromULS: ulsReady ? bulkUpdateCountyFromULS : undefined,
|
||||
onSendTo: bulkSendTo, onSendRecording: bulkSendRecording, onSendEQSL: (ids: number[]) => setEqslQsoId(ids[0] ?? null),
|
||||
onDelete: (ids: number[]) => setDeletingIds(ids),
|
||||
}} />
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useI18n } from '@/lib/i18n';
|
||||
import {
|
||||
GetAwardDefs, SaveAwardDefs, ResetAwardDefs, AwardFields,
|
||||
GetAwardReferenceMeta, UpdateAwardReferenceList,
|
||||
ListAwardReferences, SearchAwardReferences, SaveAwardReference, DeleteAwardReference,
|
||||
ListAwardReferences, SearchAwardReferences, SaveAwardReference, DeleteAwardReference, RenameAwardReference,
|
||||
ImportAwardReferencesText, GetAwardPresets, ApplyAwardPreset,
|
||||
ListCountries, DXCCForCountry, DXCCName,
|
||||
PopulateBuiltinReferences, HasBuiltinReferences,
|
||||
@@ -915,6 +915,10 @@ function ReferencesPanel({ code, presets, meta, awardValidFrom, awardValidTo, on
|
||||
const [refs, setRefs] = useState<AwardRef[]>([]);
|
||||
const [q, setQ] = useState('');
|
||||
const [selCode, setSelCode] = useState<string | null>(null);
|
||||
// The code as TYPED. The list and every patch key off selCode, so editing the
|
||||
// code in place would make the editor lose the reference mid-edit; the draft
|
||||
// is applied as a rename when the operator saves.
|
||||
const [codeDraft, setCodeDraft] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [bulk, setBulk] = useState('');
|
||||
const [showBulk, setShowBulk] = useState(false);
|
||||
@@ -952,6 +956,7 @@ function ReferencesPanel({ code, presets, meta, awardValidFrom, awardValidTo, on
|
||||
}
|
||||
|
||||
const sel = refs.find((r) => r.code === selCode) || null;
|
||||
useEffect(() => { setCodeDraft(selCode ?? ''); }, [selCode]);
|
||||
// Large lists are already filtered by the server; small lists filter locally.
|
||||
const filtered = useMemo(() => {
|
||||
if (large) return refs;
|
||||
@@ -965,6 +970,27 @@ function ReferencesPanel({ code, presets, meta, awardValidFrom, awardValidTo, on
|
||||
try { await SaveAwardReference(code, r as any); load(); onChanged(); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
// Save the selected reference, renumbering it first when the code was edited.
|
||||
//
|
||||
// The rename has to come first and has to be a rename: saving under the new
|
||||
// code would simply create a second reference and leave the old one behind,
|
||||
// which is how a list quietly grows duplicates.
|
||||
async function saveSelected(r: AwardRef) {
|
||||
const next = codeDraft.trim().toUpperCase();
|
||||
if (!next) { setErr(t('awed.refCodeEmpty')); return; }
|
||||
if (next !== r.code) {
|
||||
try {
|
||||
await RenameAwardReference(code, r.code, next);
|
||||
} catch (e: any) {
|
||||
// Most often the number is already taken by another reference. Said
|
||||
// here rather than swallowed: the save has NOT happened.
|
||||
setErr(String(e?.message ?? e));
|
||||
return;
|
||||
}
|
||||
setSelCode(next);
|
||||
}
|
||||
await saveRef({ ...r, code: next });
|
||||
}
|
||||
async function addRef() {
|
||||
const c = prompt(t('awed.newRefCodePrompt'))?.trim().toUpperCase();
|
||||
if (!c) return;
|
||||
@@ -1046,7 +1072,13 @@ function ReferencesPanel({ code, presets, meta, awardValidFrom, awardValidTo, on
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input className="h-8 w-28 font-mono font-semibold" value={sel.code} readOnly />
|
||||
{/* Editable, because a shipped list can be wrong about it: WAJA
|
||||
went out carrying Japan's civil prefecture numbers instead of
|
||||
the JARL's, and correcting that meant deleting all 47
|
||||
references and importing a new list. */}
|
||||
<Input className="h-8 w-28 font-mono font-semibold" value={codeDraft}
|
||||
title={t('awed.refCodeTip')}
|
||||
onChange={(e) => setCodeDraft(e.target.value)} />
|
||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer"><Checkbox checked={sel.valid} onCheckedChange={(c) => patchSel({ valid: !!c })} /> {t('awed.valid')}</label>
|
||||
<div className="flex-1" />
|
||||
<button className="text-muted-foreground hover:text-destructive" onClick={() => delRef(sel.code)}><Trash2 className="size-4" /></button>
|
||||
@@ -1084,7 +1116,7 @@ function ReferencesPanel({ code, presets, meta, awardValidFrom, awardValidTo, on
|
||||
? t('awed.refValidHintAward', { from: openEnded(awardValidFrom), to: openEnded(awardValidTo) })
|
||||
: t('awed.refValidHint')}
|
||||
</p>
|
||||
<div className="flex justify-end pt-1"><Button size="sm" className="h-7" onClick={() => sel && saveRef(sel)}><Save className="size-3.5 mr-1" /> {t('awed.saveReference')}</Button></div>
|
||||
<div className="flex justify-end pt-1"><Button size="sm" className="h-7" onClick={() => sel && saveSelected(sel)}><Save className="size-3.5 mr-1" /> {t('awed.saveReference')}</Button></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -64,7 +64,9 @@ function ProgressBar({ worked, confirmed, total }: { worked: number; confirmed:
|
||||
);
|
||||
}
|
||||
|
||||
type AwardListItem = { code: string; name: string; valid?: boolean; bands?: string[]; emission?: string[] };
|
||||
// scoped: the award is limited to one or more DXCC entities. Missing-reference
|
||||
// detection only means anything for those — see the Missing refs button.
|
||||
type AwardListItem = { code: string; name: string; valid?: boolean; bands?: string[]; emission?: string[]; scoped?: boolean };
|
||||
|
||||
export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: number) => void; onAwardsChanged?: () => void } = {}) {
|
||||
const { t } = useI18n();
|
||||
@@ -150,7 +152,7 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
||||
]);
|
||||
const follow = new Set(tracked);
|
||||
let list: AwardListItem[] = defs
|
||||
.map((d) => ({ code: d.code, name: d.name, valid: d.valid, bands: d.valid_bands ?? [], emission: d.emission ?? [] }))
|
||||
.map((d) => ({ code: d.code, name: d.name, valid: d.valid, bands: d.valid_bands ?? [], emission: d.emission ?? [], scoped: (d.dxcc_filter ?? []).length > 0 }))
|
||||
.sort((a, b) => a.code.localeCompare(b.code));
|
||||
if (follow.size > 0) list = list.filter((a) => follow.has(a.code));
|
||||
setAwardList(list);
|
||||
@@ -427,13 +429,21 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{filteredRefs.length} {t('awp.refs')}</span>
|
||||
<button
|
||||
onClick={() => setShowMissing(true)}
|
||||
className="flex items-center gap-1 text-xs text-warning-muted-foreground hover:text-warning border border-warning-border bg-warning-muted rounded px-2 py-1"
|
||||
title={t('awp.missingRefsTitle')}
|
||||
>
|
||||
<AlertTriangle className="size-3" /> {t('awp.missingRefs')}
|
||||
</button>
|
||||
{/* Only for an award scoped to a DXCC entity. "In this award's
|
||||
scope but with no reference" needs a scope to be in: on a
|
||||
worldwide reference award — POTA, SOTA, IOTA, WWFF — every
|
||||
contact anywhere would qualify, so the answer is always none.
|
||||
The button used to be there regardless and could only ever
|
||||
open a window saying it had found nothing. */}
|
||||
{awardList.find((a) => a.code === selected)?.scoped && (
|
||||
<button
|
||||
onClick={() => setShowMissing(true)}
|
||||
className="flex items-center gap-1 text-xs text-warning-muted-foreground hover:text-warning border border-warning-border bg-warning-muted rounded px-2 py-1"
|
||||
title={t('awp.missingRefsTitle')}
|
||||
>
|
||||
<AlertTriangle className="size-3" /> {t('awp.missingRefs')}
|
||||
</button>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{/* Legend */}
|
||||
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
|
||||
@@ -608,6 +618,10 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
||||
// the missing reference (e.g. a department for DDFM).
|
||||
type MissingSortKey = 'qso_date' | 'callsign' | 'band' | 'mode' | 'country' | 'qth';
|
||||
|
||||
// How many references the assign dropdown will put in the DOM at once. Above
|
||||
// this a search box appears beside it and the rest are held back, counted.
|
||||
const REF_MENU_MAX = 300;
|
||||
|
||||
function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; name: string; onClose: () => void; onEditQSO?: (id: number) => void }) {
|
||||
const { t } = useI18n();
|
||||
const [qsos, setQsos] = useState<any[]>([]);
|
||||
@@ -616,6 +630,7 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
|
||||
const [sortKey, setSortKey] = useState<MissingSortKey>('callsign');
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
||||
const [refs, setRefs] = useState<Array<{ code: string; name: string }>>([]);
|
||||
const [refSearch, setRefSearch] = useState('');
|
||||
const [assignRef, setAssignRef] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
@@ -629,12 +644,38 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
useEffect(() => { load(); }, [code]);
|
||||
// The award's reference list drives the "assign" dropdown (e.g. China provinces).
|
||||
// The award's reference list drives the "assign" dropdown (e.g. China
|
||||
// provinces) — fetched ONLY once there is something to assign it to.
|
||||
//
|
||||
// Missing-reference detection needs a DXCC scope, so a worldwide award like
|
||||
// POTA always has zero rows here and says so. It was still loading every
|
||||
// reference behind that message: on a log with the full POTA park list
|
||||
// imported, tens of thousands of them, feeding a dropdown that could not be
|
||||
// used for anything. That is the window freezing on a screen with nothing to
|
||||
// offer.
|
||||
useEffect(() => {
|
||||
if (loading || qsos.length === 0) { setRefs([]); return; }
|
||||
ListAwardReferences(code)
|
||||
.then((r) => setRefs(((r ?? []) as any[]).map((x) => ({ code: String(x.code).toUpperCase(), name: String(x.name ?? '') }))))
|
||||
.catch(() => setRefs([]));
|
||||
}, [code]);
|
||||
}, [code, loading, qsos.length]);
|
||||
|
||||
// What the assign dropdown actually renders. Bounded, and it says how many it
|
||||
// is holding back rather than silently showing the first few hundred.
|
||||
const shownRefs = useMemo(() => {
|
||||
const q = refSearch.trim().toUpperCase();
|
||||
const rows = q
|
||||
? refs.filter((r) => r.code.includes(q) || r.name.toUpperCase().includes(q))
|
||||
: refs;
|
||||
return rows.slice(0, REF_MENU_MAX);
|
||||
}, [refs, refSearch]);
|
||||
const hiddenRefs = useMemo(() => {
|
||||
const q = refSearch.trim().toUpperCase();
|
||||
const total = q
|
||||
? refs.filter((r) => r.code.includes(q) || r.name.toUpperCase().includes(q)).length
|
||||
: refs.length;
|
||||
return Math.max(0, total - shownRefs.length);
|
||||
}, [refs, refSearch, shownRefs.length]);
|
||||
|
||||
const qthOf = (q: any) => String(q.qth || q.notes || '');
|
||||
const sorted = useMemo(() => {
|
||||
@@ -711,14 +752,29 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
|
||||
{/* Bulk-assign toolbar */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-border/50 bg-muted/20">
|
||||
<span className="text-xs text-muted-foreground">{t('awp.selectedArrow', { n: sel.size })}</span>
|
||||
{/* A search box beside the dropdown, and a bounded list inside it.
|
||||
A reference list can hold tens of thousands of entries (POTA
|
||||
parks, Russian districts); every one of them as a menu item is
|
||||
hundreds of thousands of DOM nodes and a window that stops
|
||||
answering. Nobody scrolls to K-4521 anyway — they type it. */}
|
||||
{refs.length > REF_MENU_MAX && (
|
||||
<Input className="h-7 w-40 text-xs font-mono" value={refSearch}
|
||||
placeholder={t('awp.filterReferences')}
|
||||
onChange={(e) => setRefSearch(e.target.value)} />
|
||||
)}
|
||||
<Select value={assignRef} onValueChange={setAssignRef}>
|
||||
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue placeholder={t('awp.chooseReference')} /></SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
{refs.map((r) => (
|
||||
{shownRefs.map((r) => (
|
||||
<SelectItem key={r.code} value={r.code}>
|
||||
<span className="font-mono font-semibold">{r.code}</span>{r.name ? <span className="text-muted-foreground"> · {r.name}</span> : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
{hiddenRefs > 0 && (
|
||||
<div className="px-2 py-1.5 text-[11px] text-muted-foreground">
|
||||
{t('awp.refsNarrow', { n: hiddenRefs })}
|
||||
</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="sm" disabled={!assignRef || sel.size === 0 || busy} onClick={applyAssign}>
|
||||
|
||||
@@ -49,6 +49,7 @@ type QSOMenuHandlers = {
|
||||
onUpdateFromCty?: (ids: number[]) => void;
|
||||
onUpdateFromQRZ?: (ids: number[]) => void;
|
||||
onUpdateFromClublog?: (ids: number[]) => void;
|
||||
onUpdateCountyFromULS?: (ids: number[]) => void;
|
||||
onSendTo?: (service: string, ids: number[]) => void;
|
||||
onSendRecording?: (ids: number[]) => void;
|
||||
onSendEQSL?: (ids: number[]) => void;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown, PencilLine, Trash2 } from 'lucide-react';
|
||||
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown, PencilLine, Trash2, MapPin } from 'lucide-react';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export type QSOMenuState = { x: number; y: number; ids: number[] } | null;
|
||||
@@ -10,6 +10,9 @@ type Props = {
|
||||
onUpdateFromCty: (ids: number[]) => void;
|
||||
onUpdateFromQRZ: (ids: number[]) => void;
|
||||
onUpdateFromClublog?: (ids: number[]) => void;
|
||||
// Only passed once the offline US county database has been downloaded —
|
||||
// an entry that can only ever answer "no database" is not worth a line here.
|
||||
onUpdateCountyFromULS?: (ids: number[]) => void;
|
||||
onSendTo?: (service: string, ids: number[]) => void;
|
||||
onSendRecording?: (ids: number[]) => void;
|
||||
onSendEQSL?: (ids: number[]) => void;
|
||||
@@ -36,7 +39,7 @@ const UPLOAD_TARGETS: { service: string; name: string }[] = [
|
||||
// or picks a command. (We deliberately do NOT close on scroll/resize: the QSO
|
||||
// list auto-refreshes and AG Grid fires internal scroll events on refresh,
|
||||
// which used to dismiss the menu the instant it appeared.)
|
||||
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) {
|
||||
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onUpdateCountyFromULS, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) {
|
||||
const { t } = useI18n();
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
// Starts at the cursor; the layout effect corrects it once the real size is
|
||||
@@ -118,6 +121,16 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
||||
<span>{t('qctx.updateClublog')}</span>
|
||||
</button>
|
||||
)}
|
||||
{onUpdateCountyFromULS && (
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
onClick={() => { onUpdateCountyFromULS(menu.ids); onClose(); }}
|
||||
title={t('qctx.updateUlsCountyTitle')}
|
||||
>
|
||||
<MapPin className="size-4 text-info" />
|
||||
<span>{t('qctx.updateUlsCounty')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(onSendRecording || onSendEQSL) && (
|
||||
<>
|
||||
|
||||
@@ -374,7 +374,10 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
// refreshes it. A cached answer from a thinner QRZ subscription (or any
|
||||
// stale row) otherwise stayed for its whole 30-day life and the button
|
||||
// appeared to do nothing.
|
||||
const r: any = await LookupCallsignFresh(call);
|
||||
// The QSO's OWN date: a ClubLog exception is resolved as of when the
|
||||
// contact happened, not as of today. Re-looking-up a DXpedition contact
|
||||
// months later must not move it to whatever the prefix means now.
|
||||
const r: any = await LookupCallsignFresh(call, (dateOn || '').slice(0, 10));
|
||||
// The lookup WINS over what is in the record — that is the point of asking
|
||||
// for it. But an EMPTY result must never blank a good value: `??` only
|
||||
// guards against null, and Go marshals an unset string as "", so a QRZ
|
||||
|
||||
@@ -64,6 +64,7 @@ type Props = {
|
||||
onUpdateFromCty?: (ids: number[]) => void;
|
||||
onUpdateFromQRZ?: (ids: number[]) => void;
|
||||
onUpdateFromClublog?: (ids: number[]) => void;
|
||||
onUpdateCountyFromULS?: (ids: number[]) => void;
|
||||
onSendTo?: (service: string, ids: number[]) => void;
|
||||
onSendRecording?: (ids: number[]) => void;
|
||||
onSendEQSL?: (ids: number[]) => void;
|
||||
@@ -308,7 +309,7 @@ const sanitizeAwardCols = (st: any[] | null | undefined): any[] =>
|
||||
return rest;
|
||||
});
|
||||
|
||||
export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onRowSelectedQso, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols, rowColors }: Props) {
|
||||
export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onRowSelectedQso, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onUpdateCountyFromULS, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols, rowColors }: Props) {
|
||||
const { t } = useI18n();
|
||||
const gridRef = useRef<any>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
@@ -716,6 +717,7 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
|
||||
onUpdateFromCty={(ids) => onUpdateFromCty?.(ids)}
|
||||
onUpdateFromQRZ={(ids) => onUpdateFromQRZ?.(ids)}
|
||||
onUpdateFromClublog={onUpdateFromClublog}
|
||||
onUpdateCountyFromULS={onUpdateCountyFromULS}
|
||||
onSendTo={onSendTo}
|
||||
onSendRecording={onSendRecording}
|
||||
onSendEQSL={onSendEQSL}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
|
||||
GetAntGeniusSettings, SaveAntGeniusSettings,
|
||||
GetTunerGeniusSettings, SaveTunerGeniusSettings,
|
||||
GetPSUSettings, SavePSUSettings,
|
||||
GetAmplifiers, SaveAmplifiers, GetAmpStatuses, AmpOperate,
|
||||
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
||||
GetAudioSettings, SaveAudioSettings, AudioApplyLevels, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
|
||||
@@ -50,6 +51,7 @@ import {
|
||||
GetUIPref, SetUIPref,
|
||||
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas, GetFlexBandPower, SaveFlexBandPower,
|
||||
GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile,
|
||||
GetFolderSync, SaveFolderSync, PickFolderSyncFolder, GetFolderSyncStatus, SyncFolderNow,
|
||||
GetRelayAuto, SaveRelayAuto, GetStationDevices,
|
||||
GetAwardDefs, GetTrackedAwards, SaveTrackedAwards,
|
||||
GetBandOpenSettings, SaveBandOpenSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetChaseNew, SetChaseNew, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes,
|
||||
@@ -187,6 +189,7 @@ type SectionId =
|
||||
| 'external-services'
|
||||
| 'udp'
|
||||
| 'adifmon'
|
||||
| 'foldersync'
|
||||
| 'webpublish'
|
||||
| 'lookup'
|
||||
| 'lists-bands'
|
||||
@@ -203,6 +206,7 @@ type SectionId =
|
||||
| 'antenna'
|
||||
| 'antgenius'
|
||||
| 'tunergenius'
|
||||
| 'psu'
|
||||
| 'pgxl'
|
||||
| 'flex'
|
||||
| 'relayauto'
|
||||
@@ -240,6 +244,29 @@ function VendorMark({ vendor }: { vendor: 'o3a' }) {
|
||||
);
|
||||
}
|
||||
|
||||
// PanelHost renders the selected settings panel, and exists so a panel may hold
|
||||
// hooks of its own.
|
||||
//
|
||||
// The panels are nested inside SettingsModal, closing over its state — so they
|
||||
// cannot be rendered as <Panel />: a nested function is a NEW component type on
|
||||
// every parent render, which would unmount and remount the panel on each
|
||||
// keystroke. They were therefore CALLED, `PANELS[selected]()`, and a call runs
|
||||
// any hook inside them in SettingsModal's own hook list — conditionally, since
|
||||
// only the open section is called. React counts those, and the window stopped
|
||||
// drawing the moment such a section was opened (error #310, "rendered more
|
||||
// hooks than during the previous render"). It had happened once and was headed
|
||||
// off by a comment; the comment did not survive contact with the next panel.
|
||||
//
|
||||
// This host is module-scope, so its identity is stable and calling `render()`
|
||||
// inside it puts those hooks in a component context that persists. `key` is the
|
||||
// section, so switching sections REMOUNTS it — a fresh, consistent hook list per
|
||||
// section, and no panel state leaking into the next one. Within a section the
|
||||
// render prop is a new closure each parent render, which is how the panel keeps
|
||||
// seeing current values.
|
||||
function PanelHost({ render }: { render?: () => JSX.Element }) {
|
||||
return render ? render() : null;
|
||||
}
|
||||
|
||||
// buildTree returns the settings sidebar. The FlexRadio item only appears when
|
||||
// the active CAT backend is a Flex (per-band antenna config is Flex-specific).
|
||||
function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[] {
|
||||
@@ -251,6 +278,7 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
|
||||
{ kind: 'item', label: t('sec.antgenius'), id: 'antgenius', vendor: 'o3a' },
|
||||
{ kind: 'item', label: t('sec.tunergenius'), id: 'tunergenius', vendor: 'o3a' },
|
||||
{ kind: 'item', label: t('sec.pgxl'), id: 'pgxl' },
|
||||
{ kind: 'item', label: t('sec.psu'), id: 'psu' },
|
||||
...(flexAvailable ? [{ kind: 'item', label: t('sec.flex'), id: 'flex' } as TreeNode] : []),
|
||||
{ kind: 'item', label: t('sec.relayauto'), id: 'relayauto' },
|
||||
{ kind: 'item', label: t('sec.audio'), id: 'audio' },
|
||||
@@ -279,6 +307,7 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
|
||||
{ kind: 'item', label: t('sec.cluster'), id: 'cluster' },
|
||||
{ kind: 'item', label: t('sec.udp'), id: 'udp' },
|
||||
{ kind: 'item', label: t('sec.adifmon'), id: 'adifmon' },
|
||||
{ kind: 'item', label: t('sec.foldersync'), id: 'foldersync' },
|
||||
{ kind: 'item', label: t('sec.webpublish'), id: 'webpublish' },
|
||||
{ kind: 'item', label: t('sec.uscounties'), id: 'uscounties' },
|
||||
{ kind: 'item', label: t('sec.database'), id: 'database' },
|
||||
@@ -297,6 +326,7 @@ const SECTION_KEY: Partial<Record<SectionId, string>> = {
|
||||
'external-services': 'sec.external', appearance: 'sec.appearance', lookup: 'sec.lookup', 'lists-bands': 'sec.bands', 'lists-modes': 'sec.modes',
|
||||
cluster: 'sec.cluster', backup: 'sec.backup', database: 'sec.database', autostart: 'sec.autostart', udp: 'sec.udp',
|
||||
adifmon: 'sec.adifmon',
|
||||
foldersync: 'sec.foldersync',
|
||||
webpublish: 'sec.webpublish',
|
||||
uscounties: 'sec.uscounties',
|
||||
awards: 'sec.awards', cat: 'sec.cat', rotator: 'sec.rotator', winkeyer: 'sec.winkeyer', antenna: 'sec.antenna',
|
||||
@@ -679,6 +709,120 @@ function ADIFMonitorPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
// FolderSyncPanel: one operator, several PCs, one logbook through a folder they
|
||||
// already synchronise (Seafile, OneDrive, Dropbox, a NAS share).
|
||||
//
|
||||
// The status half is the point of the panel. Every part of this runs on someone
|
||||
// else's machine and on a sync client OpsLog cannot see, so "it is not working"
|
||||
// has to be answerable from here: which other PCs have written to the folder,
|
||||
// when each last did, and whether anything is sitting there unread.
|
||||
function FolderSyncPanel() {
|
||||
const { t } = useI18n();
|
||||
const [cfg, setCfg] = useState<{ enabled: boolean; folder: string; machine: string }>({ enabled: false, folder: '', machine: '' });
|
||||
const [st, setSt] = useState<any>(null);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
const [msg, setMsg] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
useEffect(() => {
|
||||
GetFolderSync()
|
||||
.then((c: any) => { if (c) setCfg({ enabled: !!c.enabled, folder: c.folder ?? '', machine: c.machine ?? '' }); })
|
||||
.catch(() => {})
|
||||
.finally(() => setLoaded(true));
|
||||
}, []);
|
||||
// Polled while the panel is open: a first setup is verified by watching the
|
||||
// other PC appear in this list, and that happens on the sync client's clock,
|
||||
// not on any action taken here.
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const tick = () => GetFolderSyncStatus().then((s: any) => { if (alive) setSt(s); }).catch(() => {});
|
||||
tick();
|
||||
const h = window.setInterval(tick, 3000);
|
||||
return () => { alive = false; window.clearInterval(h); };
|
||||
}, []);
|
||||
const save = async (next: typeof cfg) => {
|
||||
setCfg(next);
|
||||
setErr(''); setMsg('');
|
||||
try {
|
||||
await SaveFolderSync(next as any);
|
||||
setMsg(t('sync.saved'));
|
||||
GetFolderSyncStatus().then(setSt).catch(() => {});
|
||||
} catch (e: any) {
|
||||
setErr(String(e?.message ?? e));
|
||||
// The switch goes back off rather than sitting on while nothing is
|
||||
// written: a folder that refused the write test would lose every contact
|
||||
// in silence, which is the one outcome this feature must never have.
|
||||
setCfg({ ...next, enabled: false });
|
||||
}
|
||||
};
|
||||
const pick = async () => {
|
||||
try {
|
||||
const p = await PickFolderSyncFolder();
|
||||
if (p) save({ ...cfg, folder: p });
|
||||
} catch { /* dialog cancelled */ }
|
||||
};
|
||||
const syncNow = async () => {
|
||||
setBusy(true); setErr(''); setMsg('');
|
||||
try {
|
||||
const n = await SyncFolderNow();
|
||||
setMsg(t('sync.applied').replace('{n}', String(n)));
|
||||
GetFolderSyncStatus().then(setSt).catch(() => {});
|
||||
} catch (e: any) {
|
||||
setErr(String(e?.message ?? e));
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
const when = (iso: string) => (iso ? new Date(iso).toLocaleString() : t('sync.never'));
|
||||
return (
|
||||
<div className="space-y-4 max-w-2xl">
|
||||
<p className="text-xs text-muted-foreground">{t('sync.hint')}</p>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={cfg.enabled} disabled={!loaded} onCheckedChange={(c) => save({ ...cfg, enabled: !!c })} />
|
||||
{t('sync.enable')}
|
||||
</label>
|
||||
|
||||
<div className="grid grid-cols-[130px_1fr] items-center gap-2">
|
||||
<span className="text-sm">{t('sync.machine')}</span>
|
||||
<Input value={cfg.machine} placeholder="shack" className="h-8"
|
||||
onChange={(e) => setCfg({ ...cfg, machine: e.target.value })}
|
||||
onBlur={() => save(cfg)} />
|
||||
<span className="text-sm">{t('sync.folder')}</span>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="flex-1 font-mono text-xs truncate" title={cfg.folder}>{cfg.folder || '—'}</span>
|
||||
<Button variant="outline" size="sm" onClick={pick}><FolderOpen className="size-3.5 mr-1" /> {t('sync.choose')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{err && <p className="text-xs text-destructive">{err}</p>}
|
||||
{msg && <p className="text-xs text-success">{msg}</p>}
|
||||
|
||||
<SectionHeader title={t('sync.state')} />
|
||||
<div className="rounded-md border border-border bg-muted/20 p-3 space-y-2 text-xs">
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-1 text-muted-foreground">
|
||||
<span>{t('sync.thisPc')}: <span className="font-mono text-foreground">{st?.machine_id || '—'}</span></span>
|
||||
<span>{t('sync.lastSync')}: <span className="text-foreground">{when(st?.last_sync ?? '')}</span></span>
|
||||
<span>{t('sync.sent')}: <span className="text-foreground">{st?.sent ?? 0}</span></span>
|
||||
<span>{t('sync.received')}: <span className="text-foreground">{st?.received ?? 0}</span></span>
|
||||
</div>
|
||||
{st?.error && <p className="text-destructive">{st.error}</p>}
|
||||
<div className="space-y-1">
|
||||
{(st?.peers ?? []).length === 0 && <p className="italic text-muted-foreground">{t('sync.noPeers')}</p>}
|
||||
{(st?.peers ?? []).map((p: any) => (
|
||||
<div key={p.machine} className="flex items-center gap-2">
|
||||
<span className="font-mono truncate">{p.machine}</span>
|
||||
<span className="text-muted-foreground">{when(p.last_change)}</span>
|
||||
{p.behind > 0 && <span className="text-warning">{t('sync.behind')}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={syncNow} disabled={busy || !cfg.enabled}>
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin mr-1.5" /> : null}
|
||||
{t('sync.now')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// AmpUI mirrors the backend AmpConfig — one configured amplifier.
|
||||
type AmpUI = { id: string; name: string; enabled: boolean; type: string; transport: string; host: string; port: number; com_port: string; baud: number;
|
||||
// Band-follow (ACOM): a SECOND serial port on which OpsLog answers the amp's
|
||||
@@ -1259,7 +1403,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
yaesu_port: '', yaesu_baud: 38400, yaesu_low_lines: false, kenwood_low_lines: false, kenwood_port: '', kenwood_baud: 9600, kenwood_host: '', kenwood_data_mode: 'usb', xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70, xiegu_ptt_line: '',
|
||||
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false,
|
||||
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0,
|
||||
digital_default: 'FT8', share_enabled: false, share_port: 4532,
|
||||
digital_default: 'FT8', share_enabled: false, share_port: 4532, share_proto: 'rigctl', share_tci_port: 40001,
|
||||
ptt_hotkey_enabled: false, ptt_hotkey: '', ptt_hotkey_toggle: false,
|
||||
});
|
||||
// While true, the next key press is captured as the PTT hotkey.
|
||||
@@ -1278,6 +1422,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
// Antenna Genius (4O3A) switch settings — TCP port is fixed at 9007.
|
||||
const [antgenius, setAntgenius] = useState<{ enabled: boolean; host: string; password: string }>({ enabled: false, host: '', password: '' });
|
||||
const [tunergenius, setTunergenius] = useState<{ enabled: boolean; host: string; password: string }>({ enabled: false, host: '', password: '' });
|
||||
const [psuCfg, setPsuCfg] = useState<{ enabled: boolean; com_port: string; baud: number; address: number }>({ enabled: false, com_port: '', baud: 9600, address: 1 });
|
||||
|
||||
// Amplifier list — operators can run SEVERAL amps (even two SPEs combined),
|
||||
// each with its own connection. Saved as a whole via SaveAmplifiers.
|
||||
@@ -1530,8 +1675,10 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
const [eqslTesting, setEqslTesting] = useState(false);
|
||||
const [stationLocations, setStationLocations] = useState<string[]>([]);
|
||||
// Active tab in the External Services panel — lifted here because
|
||||
// PANELS[selected]() is called as a function, so panels can't hold hooks.
|
||||
// Active tab in the External Services panel. Lifted here back when a panel
|
||||
// could not hold hooks at all; PanelHost lifted that restriction, and this
|
||||
// stays put because moving it down would reset the tab on every reopen —
|
||||
// a choice now, not a workaround.
|
||||
const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'lotw' | 'cloudlog' | 'pota'>('qrz');
|
||||
// POTA hunter-log sync (stamps pota_ref on local QSOs from your pota.app log).
|
||||
const [potaToken, setPotaToken] = useState('');
|
||||
@@ -1628,9 +1775,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
return () => { unsub?.(); };
|
||||
}, []);
|
||||
const [profiles, setProfiles] = useState<Profile[]>([]);
|
||||
// State for ProfilesPanel — lifted here because PANELS[selected]() calls
|
||||
// the panel as a plain function, not as a JSX element, so any useState
|
||||
// inside the panel function would violate the Rules of Hooks.
|
||||
// State for ProfilesPanel. Lifted here back when a panel could not hold hooks
|
||||
// — PanelHost lifted that restriction — and left here because the selection
|
||||
// then survives switching sections, which is what an operator expects of it.
|
||||
const [profileSelectedId, setProfileSelectedId] = useState<number>(0);
|
||||
const [profileNameDraft, setProfileNameDraft] = useState<string>('');
|
||||
|
||||
@@ -1681,6 +1828,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
||||
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
||||
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
|
||||
try { setPsuCfg(await GetPSUSettings() as any); } catch {}
|
||||
try { setAmps(((await GetAmplifiers()) ?? []) as AmpUI[]); } catch {}
|
||||
setBackupCfg(b as any);
|
||||
setQslDefaults(qd as any);
|
||||
@@ -1723,6 +1871,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
||||
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
||||
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
|
||||
try { setPsuCfg(await GetPSUSettings() as any); } catch {}
|
||||
try { setAmps(((await GetAmplifiers()) ?? []) as AmpUI[]); } catch {}
|
||||
try { setBackupCfg(await GetBackupSettings() as any); } catch {}
|
||||
try { setQslDefaults(await GetQSLDefaults() as any); } catch {}
|
||||
@@ -1916,6 +2065,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
await SaveUltrabeamSettings(ultrabeam as any);
|
||||
await SaveAntGeniusSettings(antgenius as any);
|
||||
await SaveTunerGeniusSettings(tunergenius as any);
|
||||
await SavePSUSettings(psuCfg as any);
|
||||
await SaveAmplifiers(amps as any);
|
||||
await SaveWinkeyerSettings(wk as any);
|
||||
await SaveAudioSettings(audioCfg as any);
|
||||
@@ -2218,6 +2368,11 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
}
|
||||
|
||||
function LookupPanel() {
|
||||
// The cache lifetime as TYPED, so the box can be emptied and re-filled.
|
||||
// Re-seeded when the settings arrive from the backend — which is after the
|
||||
// first render, so it cannot simply be the initial value.
|
||||
const [ttlText, setTtlText] = useState(String(lookup.cache_ttl_days));
|
||||
useEffect(() => { setTtlText(String(lookup.cache_ttl_days)); }, [lookup.cache_ttl_days]);
|
||||
// Per-row provider editor — kept inline because it's only used twice
|
||||
// and needs closure access to the parent state.
|
||||
const row = (
|
||||
@@ -2354,16 +2509,31 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<div className="flex gap-3 items-end">
|
||||
<div className="space-y-1 w-40">
|
||||
<Label>{t('lk.ttl')}</Label>
|
||||
{/* Raw text, not the stored number. Deriving the value from the
|
||||
number on every keystroke made the box impossible to empty —
|
||||
and "0" itself unreachable, since parseInt('0') || 30 is 30.
|
||||
Zero is now a real setting, so it has to be typeable. */}
|
||||
<Input
|
||||
type="number" min={1} max={3650}
|
||||
value={lookup.cache_ttl_days}
|
||||
onChange={(e) => setLookup((s) => ({ ...s, cache_ttl_days: parseInt(e.target.value) || 30 }))}
|
||||
type="number" min={0} max={3650}
|
||||
value={ttlText}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
setTtlText(raw);
|
||||
const n = parseInt(raw, 10);
|
||||
if (Number.isFinite(n) && n >= 0) {
|
||||
setLookup((s) => ({ ...s, cache_ttl_days: Math.min(n, 3650) }));
|
||||
}
|
||||
}}
|
||||
onBlur={() => setTtlText(String(lookup.cache_ttl_days))}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" onClick={clearCache} disabled={clearing}>
|
||||
{clearing ? t('lk.clearing') : t('lk.clearCache')}
|
||||
</Button>
|
||||
</div>
|
||||
{lookup.cache_ttl_days === 0 && (
|
||||
<p className="text-[11px] text-warning mt-2">{t('lk.cacheOff')}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -2999,15 +3169,47 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</label>
|
||||
<p className="text-[11px] text-muted-foreground">{t('cat.shareHint')}</p>
|
||||
{catCfg.share_enabled && (
|
||||
<div className="space-y-1 max-w-[200px]">
|
||||
<Label>{t('cat.sharePort')}</Label>
|
||||
<PortInput
|
||||
value={catCfg.share_port || 4532}
|
||||
fallback={4532}
|
||||
onChange={(n) => setCatCfg((s) => ({ ...s, share_port: n }))}
|
||||
/>
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
{/* One protocol or the other. They answer the same questions about
|
||||
the same radio, and no program speaks both — so this is a
|
||||
choice, not two switches. */}
|
||||
<div className="space-y-1">
|
||||
<Label>{t('cat.shareProto')}</Label>
|
||||
<Select
|
||||
value={(catCfg as any).share_proto === 'tci' ? 'tci' : 'rigctl'}
|
||||
onValueChange={(v) => setCatCfg((s) => ({ ...s, share_proto: v } as any))}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[240px]"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="rigctl">{t('cat.shareRigctl')}</SelectItem>
|
||||
<SelectItem value="tci">{t('cat.shareTci')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1 max-w-[200px]">
|
||||
<Label>{t('cat.sharePort')}</Label>
|
||||
{(catCfg as any).share_proto === 'tci' ? (
|
||||
<PortInput
|
||||
value={(catCfg as any).share_tci_port || 40001}
|
||||
fallback={40001}
|
||||
onChange={(n) => setCatCfg((s) => ({ ...s, share_tci_port: n } as any))}
|
||||
/>
|
||||
) : (
|
||||
<PortInput
|
||||
value={catCfg.share_port || 4532}
|
||||
fallback={4532}
|
||||
onChange={(n) => setCatCfg((s) => ({ ...s, share_port: n }))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{catCfg.share_enabled && (catCfg as any).share_proto === 'tci' && catCfg.backend === 'tci' && (
|
||||
// Both ends TCI: ExpertSDR is almost certainly already holding
|
||||
// 40001 on this machine, and our server would fail to bind. Worth
|
||||
// saying here rather than leaving it in the log.
|
||||
<p className="text-[11px] text-warning">{t('cat.shareTciClash')}</p>
|
||||
)}
|
||||
</div>
|
||||
{/* PTT hotkey — a keyboard key that keys the rig while OpsLog is focused.
|
||||
Uses the Audio → PTT method (CAT / RTS / DTR), falling back to CAT. */}
|
||||
@@ -3079,7 +3281,19 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
setRotatorTest(null);
|
||||
try {
|
||||
await TestRotatorDevice(dev as any, sub);
|
||||
setRotatorTest({ ok: true, msg: (dev as any).type === 'rotgenius' ? t('rot.testOkRG') : t('cat.rotatorOk') });
|
||||
// Say what the test ACTUALLY did, which is not the same on every backend.
|
||||
//
|
||||
// PstRotator and a Rotator Genius are sent a move to 0°; a SPID, an ARCO
|
||||
// and a DCU-1 are asked their heading and nothing turns. They all used to
|
||||
// report "Packet sent — the antenna should swing to north. If it didn't,
|
||||
// check PstRotator's UDP listener" — which named a program that is not in
|
||||
// the path, for a move that was never commanded. An operator on a tower
|
||||
// read that as the test having failed.
|
||||
const type = (dev as any).type;
|
||||
const msg = type === 'rotgenius' ? t('rot.testOkRG')
|
||||
: (type === 'spid' || type === 'arco' || type === 'dcu1') ? t('rot.testOkRead')
|
||||
: t('cat.rotatorOk');
|
||||
setRotatorTest({ ok: true, msg });
|
||||
} catch (e: any) {
|
||||
setRotatorTest({ ok: false, msg: String(e?.message ?? e) });
|
||||
} finally {
|
||||
@@ -3383,6 +3597,58 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
);
|
||||
}
|
||||
|
||||
// Bench power supply over Modbus RTU. OpsLog reads everything and writes one
|
||||
// register — the output on/off. The voltage and current SET points are shown
|
||||
// because they are worth seeing, and are not editable here: they belong to the
|
||||
// supply's front panel, and a logbook that can set them can set them wrong.
|
||||
// The COM ports come from the list SettingsModal already loads for the
|
||||
// Winkeyer panel — one machine, one set of serial ports, fetched once. A hook
|
||||
// of its own would be legal now (see PanelHost) and would fetch them twice.
|
||||
function PSUPanelSettings() {
|
||||
const ports = wkPorts;
|
||||
const setPorts = setWkPorts;
|
||||
return (
|
||||
<>
|
||||
<SectionHeader title={t('psu.title')} hint={t('psu.hint')} />
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={psuCfg.enabled} onCheckedChange={(c) => setPsuCfg((s) => ({ ...s, enabled: !!c }))} />
|
||||
{t('psu.enable')}
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>{t('psu.port')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={psuCfg.com_port || '_'} onValueChange={(v) => setPsuCfg((s) => ({ ...s, com_port: v === '_' ? '' : v }))}>
|
||||
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{ports.length === 0 && <SelectItem value="_" disabled>{t('station.noPorts')}</SelectItem>}
|
||||
{ports.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="sm" variant="outline" onClick={() => ListSerialPorts().then((p) => setPorts((p ?? []) as string[])).catch(() => {})}>
|
||||
↻
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('psu.baud')}</Label>
|
||||
<Input className="font-mono" value={String(psuCfg.baud ?? 9600)}
|
||||
onChange={(e) => setPsuCfg((s) => ({ ...s, baud: parseInt(e.target.value.replace(/[^0-9]/g, ''), 10) || 0 }))} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('psu.address')}</Label>
|
||||
<Input className="font-mono" value={String(psuCfg.address ?? 1)}
|
||||
onChange={(e) => setPsuCfg((s) => ({ ...s, address: parseInt(e.target.value.replace(/[^0-9]/g, ''), 10) || 0 }))} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('psu.wireHint')}</p>
|
||||
<p className="text-xs text-warning">{t('psu.writeScope')}</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PGXLPanelSettings() {
|
||||
// The stored `type` stays a flat value ("spe13", "acom700", "pgxl"); the UI
|
||||
// presents it as brand + model.
|
||||
@@ -4645,10 +4911,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<SectionHeader
|
||||
title={t('sec.backup')}
|
||||
hint={mysqlCfg.enabled ? t('bk.hintMysql') : t('bk.hint')}
|
||||
/>
|
||||
{/* No hint: everyone knows what a backup is. */}
|
||||
<SectionHeader title={t('sec.backup')} />
|
||||
<div className="space-y-5 max-w-2xl">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox
|
||||
@@ -5566,7 +5830,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<Button variant="outline" size="sm" onClick={openExisting}><Database className="size-3.5" /> {t('db.openExisting')}</Button>
|
||||
<Button variant="outline" size="sm" onClick={saveCopy}><Copy className="size-3.5" /> {t('db.saveCopy')}</Button>
|
||||
<Button variant="outline" size="sm" onClick={renameDb} title={t('db.renameTip')}><Pencil className="size-3.5" /> {t('db.rename')}</Button>
|
||||
<Button variant="outline" size="sm" onClick={revealFolder}><FolderOpen className="size-3.5" /> {t('db.openFolder')}</Button>
|
||||
{/* Pushed right: these two act on the file that is already there,
|
||||
while the four on the left change WHICH file is in use. */}
|
||||
<Button variant="outline" size="sm" className="ml-auto" onClick={revealFolder}><FolderOpen className="size-3.5" /> {t('db.openFolder')}</Button>
|
||||
{dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>{t('db.resetDefault')}</Button>}
|
||||
</div>
|
||||
{/* The DB pointer is only read at startup, so offer the restart inline. */}
|
||||
@@ -5599,7 +5865,6 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground max-w-2xl mb-3">{t('db.profileHint')}</p>
|
||||
|
||||
{/* Compact active-backend confirmation / MySQL-fallback warning. */}
|
||||
{backendStatus && (
|
||||
@@ -5640,7 +5905,6 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
{/* MySQL: shared logbook connection (multi-operator) */}
|
||||
{mysqlCfg.enabled && (
|
||||
<div className="space-y-3 max-w-2xl">
|
||||
<div className="text-[11px] text-muted-foreground leading-relaxed">{t('db.mysqlHint')}</div>
|
||||
<div className="grid grid-cols-[130px_1fr] gap-2 items-center">
|
||||
<Label className="text-sm">{t('db.host')}</Label>
|
||||
<Input className="h-8" placeholder="192.168.1.10 or db.example.com" value={mysqlCfg.host} onChange={(e) => setMysqlField({ host: e.target.value })} />
|
||||
@@ -6131,12 +6395,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
}}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
{t('gen.mwShow')}
|
||||
<span className="block text-xs text-muted-foreground mt-0.5">
|
||||
{t('gen.mwDesc')}
|
||||
</span>
|
||||
</span>
|
||||
{/* No explanatory line: every operator knows what Most Wanted is. */}
|
||||
<span>{t('gen.mwShow')}</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-3 pl-6">
|
||||
<Button variant="outline" size="sm" className="h-8" disabled={mwBusy}
|
||||
@@ -6332,10 +6592,11 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
'lists-modes': ModesPanel,
|
||||
cluster: ClusterPanel,
|
||||
udp: UDPIntegrationsPanelWrapper,
|
||||
// Rendered as a real element (not called as a bare function) so its own hooks
|
||||
// — useState/useEffect/useI18n — get a proper component context; PANELS[x]()
|
||||
// is a plain call and hook-holding panels must go through JSX like this.
|
||||
// Module-scope components, wrapped so their props can be passed. The nested
|
||||
// panels below go through PanelHost instead — which is what now lets either
|
||||
// kind hold hooks.
|
||||
adifmon: () => <ADIFMonitorPanel />,
|
||||
foldersync: () => <FolderSyncPanel />,
|
||||
webpublish: () => <WebPublishPanel />,
|
||||
relayauto: () => <RelayAutoPanel />,
|
||||
backup: BackupPanel,
|
||||
@@ -6349,6 +6610,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
antenna: UltrabeamPanel,
|
||||
antgenius: AntGeniusPanelSettings,
|
||||
tunergenius: TunerGeniusPanelSettings,
|
||||
psu: PSUPanelSettings,
|
||||
pgxl: PGXLPanelSettings,
|
||||
flex: () => <FlexBandPanel bands={lists.bands ?? []} />,
|
||||
audio: AudioPanel,
|
||||
@@ -6376,7 +6638,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-3 font-semibold">
|
||||
{breadcrumb}
|
||||
</div>
|
||||
{PANELS[selected]?.()}
|
||||
<PanelHost key={selected} render={PANELS[selected]} />
|
||||
|
||||
{err && (
|
||||
<div className="mt-6 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded-md px-3 py-2 max-w-2xl">
|
||||
|
||||
@@ -3,31 +3,87 @@ import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Squar
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
import { subscribeRotorHeading, pokeRotorHeading } from '@/lib/rotorHeading';
|
||||
import { RotorCompass } from '@/components/RotorCompass';
|
||||
import { AmpCard } from '@/components/AmpCard';
|
||||
import { TunerCard } from '@/components/TunerCard';
|
||||
import type { TGStatus } from '@/components/TunerGeniusPanel';
|
||||
import {
|
||||
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
|
||||
GetRotatorHeading, RotatorGoTo, RotatorStop, SetActiveRotor,
|
||||
RotatorGoTo, RotatorStop, SetActiveRotor,
|
||||
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
|
||||
MotorTuneKHz, MotorNudgeKHz, SetMotorFollow,
|
||||
ListDenkoviDevices, ListSerialPorts, TestStationDevice,
|
||||
GetAmpStatuses, GetFlexState,
|
||||
GetTunerGeniusStatus, GetTunerGeniusSettings,
|
||||
GetPSUStatus, GetPSUSettings, SetPSUOutput,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
|
||||
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
|
||||
|
||||
type PSUState = { connected: boolean; on: boolean; volts: number; amps: number; watts: number; set_volts: number; set_amps: number; protected: number; error?: string };
|
||||
|
||||
// The bench supply. One button — the output — and the three numbers that say
|
||||
// what it is actually delivering.
|
||||
//
|
||||
// The SET points are shown beside them, small, because "13.8 V set" next to
|
||||
// "0.02 A out" is how an operator sees at a glance that the supply is on but
|
||||
// the radio is not drawing. They are not editable: OpsLog reads them and never
|
||||
// writes them, which is the whole safety story of this device.
|
||||
function PSUCard({ st, busy, onToggle, t }: {
|
||||
st: PSUState; busy: boolean; onToggle: (on: boolean) => void; t: (k: string, v?: any) => string;
|
||||
}) {
|
||||
const tripped = (st.protected ?? 0) !== 0;
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||
<Power className="size-4 text-primary" />
|
||||
<div className="text-sm font-semibold truncate">{t('psu.title')}</div>
|
||||
<span className={cn('ml-auto size-2 rounded-full shrink-0', st.connected ? 'bg-success' : 'bg-muted-foreground/40')}
|
||||
title={st.connected ? t('station.online') : (st.error || t('psu.offline'))} />
|
||||
</div>
|
||||
<div className="p-3 space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button" disabled={!st.connected || busy}
|
||||
onClick={() => onToggle(!st.on)}
|
||||
className={cn('flex items-center gap-2 rounded-md border px-3 py-1.5 transition-colors disabled:opacity-40',
|
||||
st.on ? 'bg-success/15 border-success/50' : 'bg-muted/30 border-border hover:bg-muted')}>
|
||||
<span className={cn('flex items-center justify-center size-6 rounded shrink-0',
|
||||
st.on ? 'bg-success text-success-foreground' : 'bg-muted-foreground/15 text-muted-foreground')}>
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Power className="size-3.5" />}
|
||||
</span>
|
||||
<span className="text-xs font-semibold">{t('psu.output')}</span>
|
||||
<span className={cn('text-[10px] font-bold', st.on ? 'text-success' : 'text-muted-foreground/60')}>
|
||||
{st.on ? t('station.on') : t('station.off')}
|
||||
</span>
|
||||
</button>
|
||||
<div className="flex-1 min-w-0 font-mono tabular-nums text-right">
|
||||
<span className="text-lg font-bold">{(st.volts ?? 0).toFixed(2)}</span><span className="text-xs text-muted-foreground"> V</span>
|
||||
<span className="text-lg font-bold ml-3">{(st.amps ?? 0).toFixed(3)}</span><span className="text-xs text-muted-foreground"> A</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[11px] text-muted-foreground font-mono">
|
||||
<span>{(st.watts ?? 0).toFixed(1)} W</span>
|
||||
<span>{t('psu.setTo')} {(st.set_volts ?? 0).toFixed(2)} V / {(st.set_amps ?? 0).toFixed(3)} A</span>
|
||||
</div>
|
||||
{tripped && (
|
||||
<div className="text-[11px] font-bold text-danger">{t('psu.tripped')} (0x{(st.protected ?? 0).toString(16).toUpperCase()})</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Device = {
|
||||
id: string; type: string; name: string; host: string;
|
||||
user?: string; pass?: string; channels?: number; labels: string[];
|
||||
// Generic HTTP board only. The per-relay URLs win over the patterns.
|
||||
on_urls?: string[]; off_urls?: string[]; on_pattern?: string; off_pattern?: string;
|
||||
on_urls?: string[]; off_urls?: string[]; on_pattern?: string; off_pattern?: string; insecure_tls?: boolean;
|
||||
};
|
||||
type Relay = { number: number; label: string; on: boolean };
|
||||
type DevStatus = { id: string; name: string; type: string; connected: boolean; error?: string; relays: Relay[] };
|
||||
@@ -457,6 +513,32 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
||||
return () => { alive = false; window.clearInterval(id); };
|
||||
}, []);
|
||||
|
||||
// Bench power supply. Polled slower than the tuner: it has no meters that
|
||||
// track a transmission, and every poll is three Modbus exchanges on a 9600
|
||||
// baud line the operator may also be using to switch the output.
|
||||
const [psu, setPsu] = useState<PSUState>({ connected: false, on: false, volts: 0, amps: 0, watts: 0, set_volts: 0, set_amps: 0, protected: 0 });
|
||||
const [psuEnabled, setPsuEnabled] = useState(false);
|
||||
const [psuBusy, setPsuBusy] = useState(false);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const load = async () => {
|
||||
try { const en: any = await GetPSUSettings(); if (alive) setPsuEnabled(!!en?.enabled); } catch {}
|
||||
try { const st: any = await GetPSUStatus(); if (alive && st) setPsu(st as PSUState); } catch {}
|
||||
};
|
||||
load();
|
||||
const id = window.setInterval(load, 1500);
|
||||
return () => { alive = false; window.clearInterval(id); };
|
||||
}, []);
|
||||
const togglePSU = useCallback(async (on: boolean) => {
|
||||
setPsuBusy(true);
|
||||
// No optimistic flip here, unlike the relays: the supply echoes the value it
|
||||
// actually set, so showing ON before it confirms would be showing something
|
||||
// OpsLog does not know. A power switch is the wrong place to guess.
|
||||
try { await SetPSUOutput(on); const st: any = await GetPSUStatus(); if (st) setPsu(st as PSUState); }
|
||||
catch { /* the poll will tell the truth */ }
|
||||
finally { setPsuBusy(false); }
|
||||
}, []);
|
||||
|
||||
const loadDevices = useCallback(async () => {
|
||||
try { setDevices(((await GetStationDevices()) ?? []) as Device[]); } catch { /* db not ready */ }
|
||||
}, []);
|
||||
@@ -467,19 +549,21 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
||||
setStatus(Object.fromEntries(s.map((d) => [d.id, d])));
|
||||
} catch { /* ignore transient */ }
|
||||
}, []);
|
||||
const pollRot = useCallback(async () => {
|
||||
try { setRot((await GetRotatorHeading()) as any); } catch { /* ignore */ }
|
||||
}, []);
|
||||
const pollAnt = useCallback(async () => {
|
||||
try { setAnt((await GetUltrabeamStatus()) as any); } catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadDevices(); }, [loadDevices]);
|
||||
// The heading comes from the shared poller: one loop for the whole app, fast
|
||||
// while the antenna turns and slow while it is parked. Every poll opens and
|
||||
// closes the controller's port, and this panel used to run its own alongside
|
||||
// the status bar's — the same controller asked twice over.
|
||||
useEffect(() => subscribeRotorHeading((h) => setRot(h as any)), []);
|
||||
useEffect(() => {
|
||||
poll(); pollRot(); pollAnt();
|
||||
const id = window.setInterval(() => { poll(); pollRot(); pollAnt(); }, 3000);
|
||||
poll(); pollAnt();
|
||||
const id = window.setInterval(() => { poll(); pollAnt(); }, 3000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [poll, pollRot, pollAnt, devices.length]);
|
||||
}, [poll, pollAnt, devices.length]);
|
||||
|
||||
const persistOrder = (next: string[]) => { setOrder(next); writeUiPref('opslog.stationOrder', JSON.stringify(next)); };
|
||||
// Reorder so `dragged` lands just before `target`.
|
||||
@@ -527,16 +611,25 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
||||
const deviceCard = (dev: Device) => {
|
||||
const st = status[dev.id];
|
||||
const relays = st?.relays ?? dev.labels.map((label, i) => ({ number: i + 1, label, on: false }));
|
||||
// The generic HTTP board has no address of its own and nothing to poll: its
|
||||
// relays can each live on a different box, and no status endpoint is read
|
||||
// back. So no host under the name, no online dot, and the buttons are never
|
||||
// greyed out waiting for a connection that is never made.
|
||||
const fireAndForget = dev.type === 'httpgen';
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||
<PlugZap className="size-4 text-primary" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold truncate">{dev.name || TYPE_LABEL[dev.type]}</div>
|
||||
<div className="text-[10px] text-muted-foreground font-mono truncate">{TYPE_LABEL[dev.type]} · {dev.host}</div>
|
||||
<div className="text-[10px] text-muted-foreground font-mono truncate">
|
||||
{TYPE_LABEL[dev.type]}{fireAndForget || !dev.host ? '' : ` · ${dev.host}`}
|
||||
</div>
|
||||
</div>
|
||||
<span className={cn('ml-auto size-2 rounded-full shrink-0', st?.connected ? 'bg-success' : 'bg-muted-foreground/40')}
|
||||
title={st?.connected ? t('station.online') : (st?.error || t('station.offline'))} />
|
||||
{fireAndForget ? <span className="ml-auto" /> : (
|
||||
<span className={cn('ml-auto size-2 rounded-full shrink-0', st?.connected ? 'bg-success' : 'bg-muted-foreground/40')}
|
||||
title={st?.connected ? t('station.online') : (st?.error || t('station.offline'))} />
|
||||
)}
|
||||
<button className="text-muted-foreground hover:text-foreground" title={t('station.edit')}
|
||||
onClick={() => setEditing({ ...dev, labels: [...dev.labels] })}><Pencil className="size-3.5" /></button>
|
||||
<button className="text-muted-foreground hover:text-destructive" title={t('station.delete')}
|
||||
@@ -549,7 +642,7 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
||||
const key = `${dev.id}:${r.number}`;
|
||||
const label = r.label || `${t('station.relay')} ${r.number}`;
|
||||
return (
|
||||
<button key={r.number} type="button" disabled={!st?.connected}
|
||||
<button key={r.number} type="button" disabled={!fireAndForget && !st?.connected}
|
||||
title={label}
|
||||
onClick={() => toggle(dev, r.number, !r.on)}
|
||||
className={cn('w-[150px] flex items-center gap-1.5 rounded-md border px-2 py-1 text-left transition-colors disabled:opacity-40',
|
||||
@@ -576,7 +669,7 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
||||
// full-width, and they need that room here too.
|
||||
const widgets: { id: string; node: React.ReactNode; wide?: boolean }[] = [];
|
||||
if (rot.enabled) {
|
||||
widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pollRot} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> });
|
||||
widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pokeRotorHeading} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> });
|
||||
}
|
||||
if (ant.enabled) {
|
||||
widgets.push({ id: 'antenna', node: <MotorAntennaWidget ant={ant} refetch={pollAnt} t={t} /> });
|
||||
@@ -585,6 +678,7 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
||||
for (const amp of amps) widgets.push({ id: `amp:${amp.id}`, node: <AmpCard amp={amp} flex={flexState} t={t} />, wide: true });
|
||||
// Tuner Genius XL card (identical to the Flex panel's).
|
||||
if (tgEnabled) widgets.push({ id: 'tuner', node: <TunerCard status={tg} t={t} />, wide: true });
|
||||
if (psuEnabled) widgets.push({ id: 'psu', node: <PSUCard st={psu} busy={psuBusy} onToggle={togglePSU} t={t} /> });
|
||||
for (const dev of devices) widgets.push({ id: dev.id, node: deviceCard(dev) });
|
||||
|
||||
const rank = (id: string) => { const i = order.indexOf(id); return i < 0 ? 1e6 : i; };
|
||||
@@ -687,6 +781,19 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
||||
const isDenkovi = device.type === 'denkovi';
|
||||
const isUsbRelay = device.type === 'usbrelay';
|
||||
const isHTTPGen = device.type === 'httpgen';
|
||||
// {value} sends a relay's label, so a URL using it on an unnamed relay would
|
||||
// go out with an empty parameter. Warn while it is being typed rather than at
|
||||
// the moment an antenna fails to switch.
|
||||
const valueNeedsLabels = isHTTPGen
|
||||
&& [...(device.on_urls ?? []), ...(device.off_urls ?? []), device.on_pattern ?? '', device.off_pattern ?? '']
|
||||
.some((s) => (s ?? '').includes('{value}'))
|
||||
&& device.labels.some((l) => !l.trim());
|
||||
// Any https:// among this board's URLs. A relay box on the LAN signs its own
|
||||
// certificate, so HTTPS to one cannot be verified — the operator has to say
|
||||
// whether to accept that, and the question only arises once they type https.
|
||||
const usesHTTPS = isHTTPGen
|
||||
&& [...(device.on_urls ?? []), ...(device.off_urls ?? []), device.on_pattern ?? '', device.off_pattern ?? '']
|
||||
.some((u) => (u ?? '').trim().toLowerCase().startsWith('https://'));
|
||||
// COM ports for the generic USB-serial relay picker.
|
||||
const [serialPorts, setSerialPorts] = useState<string[]>([]);
|
||||
useEffect(() => {
|
||||
@@ -795,7 +902,12 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">{t('station.usbRelayHint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
) : isHTTPGen ? null : (
|
||||
/* No Host for the generic board: its driver never reads one. Each URL
|
||||
below carries its own address, and they need not even share it — one
|
||||
relay can sit on a different box from the next. A field that changes
|
||||
nothing is worse than no field: it reads as the thing to fill in first,
|
||||
and then the URLs look like they should be relative to it. */
|
||||
<div className={cn('grid gap-3', (isKM || isDingtian) ? 'grid-cols-3' : 'grid-cols-1')}>
|
||||
<div className={cn('space-y-1', (isKM || isDingtian) ? '' : 'max-w-xs')}>
|
||||
<Label>{t('station.host')}</Label>
|
||||
@@ -854,6 +966,19 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">{t('station.patternHint')}</div>
|
||||
{/* Shown only once an https:// URL is actually in use. A board on
|
||||
plain HTTP has no certificate to argue about, and an option that
|
||||
cannot matter yet is one more thing to wonder about. */}
|
||||
{usesHTTPS && (
|
||||
<label className="flex items-start gap-2 text-xs cursor-pointer">
|
||||
<Checkbox className="mt-0.5" checked={!!device.insecure_tls}
|
||||
onCheckedChange={(c) => onChange({ ...device, insecure_tls: !!c })} />
|
||||
<span>
|
||||
{t('station.insecureTls')}
|
||||
<span className="block text-[10px] text-muted-foreground">{t('station.insecureTlsHint')}</span>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<Label>{t('station.perRelayUrls')}</Label>
|
||||
<div className="space-y-1">
|
||||
@@ -886,6 +1011,10 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>{t('station.labels')}</Label>
|
||||
{/* {value} sends the label, so an unnamed relay would go out as "?on=".
|
||||
Said here, beside the empty box, rather than when the antenna fails
|
||||
to switch and the log is the only place that explains why. */}
|
||||
{valueNeedsLabels && <p className="text-[10px] text-warning">{t('station.valueNeedsLabels')}</p>}
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{device.labels.map((lab, i) => (
|
||||
<Input key={i} value={lab} placeholder={`${t('station.relay')} ${i + 1}`} className="h-8 text-xs"
|
||||
@@ -901,12 +1030,20 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={testDevice} disabled={testing || !device.host.trim()}>
|
||||
{testing ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <PlugZap className="size-3.5 mr-1" />}
|
||||
{t('station.test')}
|
||||
</Button>
|
||||
{/* No connection test for the generic board, and no host required to
|
||||
save it. There is nothing to test: it has no address of its own and
|
||||
no status to read — its URLs are fired and forgotten. A button that
|
||||
can only ever say "OK, 4 relays" tests nothing, and a Save greyed
|
||||
out for a missing host made a perfectly complete configuration —
|
||||
four full URLs — impossible to store. */}
|
||||
{!isHTTPGen && (
|
||||
<Button size="sm" variant="outline" onClick={testDevice} disabled={testing || !device.host.trim()}>
|
||||
{testing ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <PlugZap className="size-3.5 mr-1" />}
|
||||
{t('station.test')}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="ghost" onClick={onCancel}><X className="size-3.5 mr-1" />{t('station.cancel')}</Button>
|
||||
<Button size="sm" onClick={onSave} disabled={!device.host.trim()}><Check className="size-3.5 mr-1" />{t('station.save')}</Button>
|
||||
<Button size="sm" onClick={onSave} disabled={!isHTTPGen && !device.host.trim()}><Check className="size-3.5 mr-1" />{t('station.save')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -35,6 +35,7 @@ type Props = {
|
||||
onUpdateFromCty?: (ids: number[]) => void;
|
||||
onUpdateFromQRZ?: (ids: number[]) => void;
|
||||
onUpdateFromClublog?: (ids: number[]) => void;
|
||||
onUpdateCountyFromULS?: (ids: number[]) => void;
|
||||
onSendTo?: (service: string, ids: number[]) => void;
|
||||
onSendRecording?: (ids: number[]) => void;
|
||||
onSendEQSL?: (ids: number[]) => void;
|
||||
@@ -57,7 +58,7 @@ function fmtDate(s: any): string {
|
||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
||||
}
|
||||
|
||||
export function WorkedBeforeGrid({ wb, myGrid, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportCabrilloSelected, onDelete, awardCols }: Props) {
|
||||
export function WorkedBeforeGrid({ wb, myGrid, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onUpdateCountyFromULS, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportCabrilloSelected, onDelete, awardCols }: Props) {
|
||||
const { t } = useI18n();
|
||||
const gridRef = useRef<any>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
@@ -272,6 +273,7 @@ export function WorkedBeforeGrid({ wb, myGrid, busy, currentCall, onRowDoubleCli
|
||||
onUpdateFromCty={(ids) => onUpdateFromCty?.(ids)}
|
||||
onUpdateFromQRZ={(ids) => onUpdateFromQRZ?.(ids)}
|
||||
onUpdateFromClublog={onUpdateFromClublog}
|
||||
onUpdateCountyFromULS={onUpdateCountyFromULS}
|
||||
onSendTo={onSendTo}
|
||||
onSendRecording={onSendRecording}
|
||||
onSendEQSL={onSendEQSL}
|
||||
|
||||
@@ -192,3 +192,27 @@ export function spotRefList(byCode: Record<string, string>, fieldOf: Record<stri
|
||||
}
|
||||
return out.sort((a, b) => a.length - b.length || a.localeCompare(b));
|
||||
}
|
||||
|
||||
// withIOTARef adds an island reference from the callbook to an entry's award
|
||||
// references, unless the operator has already set one.
|
||||
//
|
||||
// QRZ sends <iota> for an operator on an island and OpsLog used to read past it.
|
||||
// It matters more here than it would for another award: unlike POTA there is no
|
||||
// live "who is on an island right now" feed anywhere, so the callbook record is
|
||||
// the practical source — and it is known before the contact is logged, which is
|
||||
// when it is useful.
|
||||
//
|
||||
// A reference the operator typed or picked wins. A callbook entry can be years
|
||||
// old, and the operator in front of the radio has just been told where the
|
||||
// station is.
|
||||
export function withIOTARef(awardRefs: string, iota: string): string {
|
||||
const ref = (iota || '').trim().toUpperCase();
|
||||
// EU-048: two letters, a hyphen, three digits. Anything else is not an IOTA
|
||||
// reference, and writing it into the award would make a reference that no
|
||||
// list contains — which counts for nothing and has to be found by hand later.
|
||||
if (!/^[A-Z]{2}-\d{3}$/.test(ref)) return awardRefs;
|
||||
const byCode = parseAwardRefs(awardRefs);
|
||||
if ((byCode['IOTA'] ?? '').trim() !== '') return awardRefs; // already set: leave it
|
||||
const sep = awardRefs.trim() === '' ? '' : ';';
|
||||
return awardRefs + sep + 'IOTA@' + ref;
|
||||
}
|
||||
|
||||
+38
-27
File diff suppressed because one or more lines are too long
@@ -0,0 +1,102 @@
|
||||
import { GetRotatorHeading } from '../../wailsjs/go/main/App';
|
||||
|
||||
// One poll loop for the antenna heading, shared by everything that shows it.
|
||||
//
|
||||
// TWO PROBLEMS THIS FIXES, both of them invisible until you count.
|
||||
//
|
||||
// The status bar polled, and the Station Control compass polled, and they polled
|
||||
// the SAME binding — so with that tab open the rotator was asked twice as often
|
||||
// as either component believed. Every backend builds a fresh client per call
|
||||
// (spid.New, gs232.NewSerial, dcu1.NewSerial, rotgenius.New…), so one poll is
|
||||
// one OPEN and CLOSE of a serial port or a TCP connection, not a read on a link
|
||||
// already up.
|
||||
//
|
||||
// And a fixed interval has no good value. Three seconds moved the needle in
|
||||
// steps of about thirteen degrees on a turning antenna — a compass that jumps
|
||||
// rather than sweeps. Seven hundred milliseconds sweeps beautifully and opens
|
||||
// the controller's port about ten thousand times an hour to watch an antenna
|
||||
// that has not moved since breakfast.
|
||||
//
|
||||
// So: fast while it is turning, slow while it is not. "Turning" is not something
|
||||
// these controllers report — a SPID answers a position and nothing else — so it
|
||||
// is inferred from the position changing, and held for a few seconds after the
|
||||
// last change so the tail of a movement stays smooth.
|
||||
|
||||
export type RotorHeading = {
|
||||
enabled: boolean; ok: boolean; azimuth: number;
|
||||
rotors?: string[]; active?: number; motorized?: boolean; raw?: string;
|
||||
};
|
||||
|
||||
const MOVING_MS = 500; // while the antenna is turning
|
||||
const IDLE_MS = 3000; // while it is parked — the rate everything used before
|
||||
const SETTLE_MS = 6000; // stay fast this long after the last movement
|
||||
|
||||
const subs = new Set<(h: RotorHeading) => void>();
|
||||
let timer: number | undefined;
|
||||
let inFlight = false;
|
||||
let lastAz: number | null = null;
|
||||
let lastMoveAt = 0;
|
||||
// The last heading anyone received, replayed to whoever subscribes next.
|
||||
//
|
||||
// Without it, opening Station Control left the compass blank for one or two
|
||||
// seconds while every other panel filled at once. Nothing was slow: the status
|
||||
// bar already had the loop running with a tick pending, so a component mounting
|
||||
// halfway through an idle interval simply waited out the rest of it. The
|
||||
// heading was known the whole time — it just had nowhere to be read from.
|
||||
let last: RotorHeading | null = null;
|
||||
|
||||
function schedule(delay: number) {
|
||||
if (timer !== undefined) window.clearTimeout(timer);
|
||||
timer = window.setTimeout(tick, delay);
|
||||
}
|
||||
|
||||
async function tick() {
|
||||
// A slow controller must not stack requests behind itself: at 600 baud a SPID
|
||||
// reply takes a fifth of a second on the wire alone, and a port that is still
|
||||
// open from the last poll cannot be opened again.
|
||||
if (inFlight) { schedule(MOVING_MS); return; }
|
||||
inFlight = true;
|
||||
try {
|
||||
const h = (await GetRotatorHeading()) as unknown as RotorHeading;
|
||||
if (h?.ok && typeof h.azimuth === 'number') {
|
||||
if (lastAz !== null && h.azimuth !== lastAz) lastMoveAt = Date.now();
|
||||
lastAz = h.azimuth;
|
||||
}
|
||||
last = h;
|
||||
subs.forEach((fn) => { try { fn(h); } catch { /* a subscriber must not stop the loop */ } });
|
||||
} catch {
|
||||
// Leave the last heading alone: a single failed poll on a shared serial port
|
||||
// is not news, and blanking the compass on one would make it flicker.
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
if (subs.size > 0) schedule(Date.now() - lastMoveAt < SETTLE_MS ? MOVING_MS : IDLE_MS);
|
||||
else timer = undefined;
|
||||
}
|
||||
|
||||
// subscribeRotorHeading starts the loop if it is not running and returns the
|
||||
// unsubscribe. The loop stops when the last subscriber leaves.
|
||||
export function subscribeRotorHeading(fn: (h: RotorHeading) => void): () => void {
|
||||
subs.add(fn);
|
||||
// Hand over what is already known, at once. An Alpha SPID poll is an open,
|
||||
// a read at 600 baud and a close, so even an immediate one takes a moment —
|
||||
// the cached heading is what makes the compass appear with the panel rather
|
||||
// than after it. In a microtask, so a subscriber is never called back before
|
||||
// subscribeRotorHeading has returned to it.
|
||||
if (last) { const h = last; queueMicrotask(() => { if (subs.has(fn)) fn(h); }); }
|
||||
if (timer === undefined && !inFlight) void tick();
|
||||
return () => {
|
||||
subs.delete(fn);
|
||||
if (subs.size === 0 && timer !== undefined) { window.clearTimeout(timer); timer = undefined; }
|
||||
};
|
||||
}
|
||||
|
||||
// pokeRotorHeading polls at once and switches to the fast rate — call it after
|
||||
// commanding a move, so the needle starts sweeping on the click rather than on
|
||||
// whatever was left of a three-second tick.
|
||||
export function pokeRotorHeading(): void {
|
||||
lastMoveAt = Date.now();
|
||||
if (timer !== undefined) window.clearTimeout(timer);
|
||||
timer = undefined;
|
||||
void tick();
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// Single source of truth for the app version shown in the UI (header + About).
|
||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||
export const APP_VERSION = '0.25.4';
|
||||
export const APP_VERSION = '0.25.8';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+27
-2
@@ -15,6 +15,7 @@ import {cluster} from '../models';
|
||||
import {extsvc} from '../models';
|
||||
import {powergenius} from '../models';
|
||||
import {pskr} from '../models';
|
||||
import {psu} from '../models';
|
||||
import {spe} from '../models';
|
||||
import {solar} from '../models';
|
||||
import {tunergenius} from '../models';
|
||||
@@ -196,6 +197,8 @@ export function DownloadULSCounties():Promise<void>;
|
||||
|
||||
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
|
||||
|
||||
export function EntryBandChanged(arg1:string):Promise<void>;
|
||||
|
||||
export function ExplainAward(arg1:string,arg2:string):Promise<Array<main.AwardExplain>>;
|
||||
|
||||
export function ExportADIF(arg1:string,arg2:boolean,arg3:Array<string>):Promise<adif.ExportResult>;
|
||||
@@ -448,6 +451,10 @@ export function GetFlexBandPower():Promise<Record<string, main.FlexBandPower>>;
|
||||
|
||||
export function GetFlexState():Promise<cat.FlexTXState>;
|
||||
|
||||
export function GetFolderSync():Promise<main.FolderSyncConfig>;
|
||||
|
||||
export function GetFolderSyncStatus():Promise<main.FolderSyncStatus>;
|
||||
|
||||
export function GetGridCacheStatus():Promise<main.GridCacheStatus>;
|
||||
|
||||
export function GetIcomState():Promise<cat.IcomTXState>;
|
||||
@@ -486,6 +493,10 @@ export function GetPOTAToken():Promise<string>;
|
||||
|
||||
export function GetPSKReporterStatus():Promise<pskr.Status>;
|
||||
|
||||
export function GetPSUSettings():Promise<main.PSUSettings>;
|
||||
|
||||
export function GetPSUStatus():Promise<psu.Status>;
|
||||
|
||||
export function GetPendingQSOs():Promise<Array<qso.QSO>>;
|
||||
|
||||
export function GetQSLDefaults():Promise<main.QSLDefaults>;
|
||||
@@ -696,9 +707,9 @@ export function LogUDPLoggedADIF(arg1:string):Promise<number>;
|
||||
|
||||
export function LogUIError(arg1:string,arg2:string,arg3:string):Promise<void>;
|
||||
|
||||
export function LookupCallsign(arg1:string):Promise<lookup.Result>;
|
||||
export function LookupCallsign(arg1:string,arg2:string):Promise<lookup.Result>;
|
||||
|
||||
export function LookupCallsignFresh(arg1:string):Promise<lookup.Result>;
|
||||
export function LookupCallsignFresh(arg1:string,arg2:string):Promise<lookup.Result>;
|
||||
|
||||
export function MotorNudgeKHz(arg1:number):Promise<void>;
|
||||
|
||||
@@ -768,6 +779,8 @@ export function PickAudioFolder():Promise<string>;
|
||||
|
||||
export function PickBackupFolder():Promise<string>;
|
||||
|
||||
export function PickFolderSyncFolder():Promise<string>;
|
||||
|
||||
export function PickOpenDatabase():Promise<string>;
|
||||
|
||||
export function PickSaveDatabase():Promise<string>;
|
||||
@@ -848,6 +861,8 @@ export function ReloadUDPIntegrations():Promise<Array<string>>;
|
||||
|
||||
export function RemovePassphrase(arg1:string):Promise<void>;
|
||||
|
||||
export function RenameAwardReference(arg1:string,arg2:string,arg3:string):Promise<void>;
|
||||
|
||||
export function RenameDatabase(arg1:string):Promise<void>;
|
||||
|
||||
export function RenameLogbook(arg1:string):Promise<void>;
|
||||
@@ -928,6 +943,8 @@ export function SaveFlexBandAntennas(arg1:Record<string, main.FlexBandAnt>):Prom
|
||||
|
||||
export function SaveFlexBandPower(arg1:Record<string, main.FlexBandPower>):Promise<void>;
|
||||
|
||||
export function SaveFolderSync(arg1:main.FolderSyncConfig):Promise<void>;
|
||||
|
||||
export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
|
||||
|
||||
export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>;
|
||||
@@ -942,6 +959,8 @@ export function SavePGXLSettings(arg1:main.PGXLSettings):Promise<void>;
|
||||
|
||||
export function SavePOTAToken(arg1:string):Promise<void>;
|
||||
|
||||
export function SavePSUSettings(arg1:main.PSUSettings):Promise<void>;
|
||||
|
||||
export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
|
||||
|
||||
export function SaveQSLDefaults(arg1:main.QSLDefaults):Promise<void>;
|
||||
@@ -1022,6 +1041,8 @@ export function SetMotorFollow(arg1:boolean,arg2:number,arg3:string):Promise<voi
|
||||
|
||||
export function SetOpsLogQSLReceived(arg1:number,arg2:boolean):Promise<void>;
|
||||
|
||||
export function SetPSUOutput(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetPassphrase(arg1:string):Promise<void>;
|
||||
|
||||
export function SetScpEnabled(arg1:boolean):Promise<void>;
|
||||
@@ -1084,6 +1105,8 @@ export function StopCWDecoder():Promise<void>;
|
||||
|
||||
export function SwitchCATRig(arg1:number):Promise<void>;
|
||||
|
||||
export function SyncFolderNow():Promise<number>;
|
||||
|
||||
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
|
||||
|
||||
export function TailLogFile(arg1:number):Promise<string>;
|
||||
@@ -1138,6 +1161,8 @@ export function UpdateAwardReferenceList(arg1:string):Promise<main.AwardRefMeta>
|
||||
|
||||
export function UpdateQSO(arg1:qso.QSO):Promise<void>;
|
||||
|
||||
export function UpdateQSOsCountyFromULS(arg1:Array<number>):Promise<number>;
|
||||
|
||||
export function UpdateQSOsFromClublog(arg1:Array<number>):Promise<number>;
|
||||
|
||||
export function UpdateQSOsFromCty(arg1:Array<number>):Promise<number>;
|
||||
|
||||
@@ -334,6 +334,10 @@ export function DuplicateProfile(arg1, arg2) {
|
||||
return window['go']['main']['App']['DuplicateProfile'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function EntryBandChanged(arg1) {
|
||||
return window['go']['main']['App']['EntryBandChanged'](arg1);
|
||||
}
|
||||
|
||||
export function ExplainAward(arg1, arg2) {
|
||||
return window['go']['main']['App']['ExplainAward'](arg1, arg2);
|
||||
}
|
||||
@@ -838,6 +842,14 @@ export function GetFlexState() {
|
||||
return window['go']['main']['App']['GetFlexState']();
|
||||
}
|
||||
|
||||
export function GetFolderSync() {
|
||||
return window['go']['main']['App']['GetFolderSync']();
|
||||
}
|
||||
|
||||
export function GetFolderSyncStatus() {
|
||||
return window['go']['main']['App']['GetFolderSyncStatus']();
|
||||
}
|
||||
|
||||
export function GetGridCacheStatus() {
|
||||
return window['go']['main']['App']['GetGridCacheStatus']();
|
||||
}
|
||||
@@ -914,6 +926,14 @@ export function GetPSKReporterStatus() {
|
||||
return window['go']['main']['App']['GetPSKReporterStatus']();
|
||||
}
|
||||
|
||||
export function GetPSUSettings() {
|
||||
return window['go']['main']['App']['GetPSUSettings']();
|
||||
}
|
||||
|
||||
export function GetPSUStatus() {
|
||||
return window['go']['main']['App']['GetPSUStatus']();
|
||||
}
|
||||
|
||||
export function GetPendingQSOs() {
|
||||
return window['go']['main']['App']['GetPendingQSOs']();
|
||||
}
|
||||
@@ -1334,12 +1354,12 @@ export function LogUIError(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['LogUIError'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function LookupCallsign(arg1) {
|
||||
return window['go']['main']['App']['LookupCallsign'](arg1);
|
||||
export function LookupCallsign(arg1, arg2) {
|
||||
return window['go']['main']['App']['LookupCallsign'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function LookupCallsignFresh(arg1) {
|
||||
return window['go']['main']['App']['LookupCallsignFresh'](arg1);
|
||||
export function LookupCallsignFresh(arg1, arg2) {
|
||||
return window['go']['main']['App']['LookupCallsignFresh'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function MotorNudgeKHz(arg1) {
|
||||
@@ -1478,6 +1498,10 @@ export function PickBackupFolder() {
|
||||
return window['go']['main']['App']['PickBackupFolder']();
|
||||
}
|
||||
|
||||
export function PickFolderSyncFolder() {
|
||||
return window['go']['main']['App']['PickFolderSyncFolder']();
|
||||
}
|
||||
|
||||
export function PickOpenDatabase() {
|
||||
return window['go']['main']['App']['PickOpenDatabase']();
|
||||
}
|
||||
@@ -1638,6 +1662,10 @@ export function RemovePassphrase(arg1) {
|
||||
return window['go']['main']['App']['RemovePassphrase'](arg1);
|
||||
}
|
||||
|
||||
export function RenameAwardReference(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['RenameAwardReference'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function RenameDatabase(arg1) {
|
||||
return window['go']['main']['App']['RenameDatabase'](arg1);
|
||||
}
|
||||
@@ -1798,6 +1826,10 @@ export function SaveFlexBandPower(arg1) {
|
||||
return window['go']['main']['App']['SaveFlexBandPower'](arg1);
|
||||
}
|
||||
|
||||
export function SaveFolderSync(arg1) {
|
||||
return window['go']['main']['App']['SaveFolderSync'](arg1);
|
||||
}
|
||||
|
||||
export function SaveListsSettings(arg1) {
|
||||
return window['go']['main']['App']['SaveListsSettings'](arg1);
|
||||
}
|
||||
@@ -1826,6 +1858,10 @@ export function SavePOTAToken(arg1) {
|
||||
return window['go']['main']['App']['SavePOTAToken'](arg1);
|
||||
}
|
||||
|
||||
export function SavePSUSettings(arg1) {
|
||||
return window['go']['main']['App']['SavePSUSettings'](arg1);
|
||||
}
|
||||
|
||||
export function SaveProfile(arg1) {
|
||||
return window['go']['main']['App']['SaveProfile'](arg1);
|
||||
}
|
||||
@@ -1986,6 +2022,10 @@ export function SetOpsLogQSLReceived(arg1, arg2) {
|
||||
return window['go']['main']['App']['SetOpsLogQSLReceived'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function SetPSUOutput(arg1) {
|
||||
return window['go']['main']['App']['SetPSUOutput'](arg1);
|
||||
}
|
||||
|
||||
export function SetPassphrase(arg1) {
|
||||
return window['go']['main']['App']['SetPassphrase'](arg1);
|
||||
}
|
||||
@@ -2110,6 +2150,10 @@ export function SwitchCATRig(arg1) {
|
||||
return window['go']['main']['App']['SwitchCATRig'](arg1);
|
||||
}
|
||||
|
||||
export function SyncFolderNow() {
|
||||
return window['go']['main']['App']['SyncFolderNow']();
|
||||
}
|
||||
|
||||
export function SyncPOTAHunterLog(arg1, arg2) {
|
||||
return window['go']['main']['App']['SyncPOTAHunterLog'](arg1, arg2);
|
||||
}
|
||||
@@ -2218,6 +2262,10 @@ export function UpdateQSO(arg1) {
|
||||
return window['go']['main']['App']['UpdateQSO'](arg1);
|
||||
}
|
||||
|
||||
export function UpdateQSOsCountyFromULS(arg1) {
|
||||
return window['go']['main']['App']['UpdateQSOsCountyFromULS'](arg1);
|
||||
}
|
||||
|
||||
export function UpdateQSOsFromClublog(arg1) {
|
||||
return window['go']['main']['App']['UpdateQSOsFromClublog'](arg1);
|
||||
}
|
||||
|
||||
@@ -1444,6 +1444,7 @@ export namespace lookup {
|
||||
email?: string;
|
||||
qsl_via?: string;
|
||||
web?: string;
|
||||
iota?: string;
|
||||
zip?: string;
|
||||
image_url?: string;
|
||||
source: string;
|
||||
@@ -1473,6 +1474,7 @@ export namespace lookup {
|
||||
this.email = source["email"];
|
||||
this.qsl_via = source["qsl_via"];
|
||||
this.web = source["web"];
|
||||
this.iota = source["iota"];
|
||||
this.zip = source["zip"];
|
||||
this.image_url = source["image_url"];
|
||||
this.source = source["source"];
|
||||
@@ -2048,6 +2050,8 @@ export namespace main {
|
||||
digital_default: string;
|
||||
share_enabled: boolean;
|
||||
share_port: number;
|
||||
share_proto: string;
|
||||
share_tci_port: number;
|
||||
ptt_hotkey_enabled: boolean;
|
||||
ptt_hotkey: string;
|
||||
ptt_hotkey_toggle: boolean;
|
||||
@@ -2094,6 +2098,8 @@ export namespace main {
|
||||
this.digital_default = source["digital_default"];
|
||||
this.share_enabled = source["share_enabled"];
|
||||
this.share_port = source["share_port"];
|
||||
this.share_proto = source["share_proto"];
|
||||
this.share_tci_port = source["share_tci_port"];
|
||||
this.ptt_hotkey_enabled = source["ptt_hotkey_enabled"];
|
||||
this.ptt_hotkey = source["ptt_hotkey"];
|
||||
this.ptt_hotkey_toggle = source["ptt_hotkey_toggle"];
|
||||
@@ -2463,6 +2469,82 @@ export namespace main {
|
||||
this.body = source["body"];
|
||||
}
|
||||
}
|
||||
export class FolderSyncConfig {
|
||||
enabled: boolean;
|
||||
folder: string;
|
||||
machine: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new FolderSyncConfig(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.enabled = source["enabled"];
|
||||
this.folder = source["folder"];
|
||||
this.machine = source["machine"];
|
||||
}
|
||||
}
|
||||
export class FolderSyncPeer {
|
||||
machine: string;
|
||||
last_change: string;
|
||||
behind: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new FolderSyncPeer(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.machine = source["machine"];
|
||||
this.last_change = source["last_change"];
|
||||
this.behind = source["behind"];
|
||||
}
|
||||
}
|
||||
export class FolderSyncStatus {
|
||||
enabled: boolean;
|
||||
folder: string;
|
||||
machine_id: string;
|
||||
peers: FolderSyncPeer[];
|
||||
last_sync: string;
|
||||
sent: number;
|
||||
received: number;
|
||||
error: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new FolderSyncStatus(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.enabled = source["enabled"];
|
||||
this.folder = source["folder"];
|
||||
this.machine_id = source["machine_id"];
|
||||
this.peers = this.convertValues(source["peers"], FolderSyncPeer);
|
||||
this.last_sync = source["last_sync"];
|
||||
this.sent = source["sent"];
|
||||
this.received = source["received"];
|
||||
this.error = source["error"];
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class GridCacheStatus {
|
||||
enabled: boolean;
|
||||
known: number;
|
||||
@@ -2733,6 +2815,24 @@ export namespace main {
|
||||
}
|
||||
}
|
||||
|
||||
export class PSUSettings {
|
||||
enabled: boolean;
|
||||
com_port: string;
|
||||
baud: number;
|
||||
address: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new PSUSettings(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.enabled = source["enabled"];
|
||||
this.com_port = source["com_port"];
|
||||
this.baud = source["baud"];
|
||||
this.address = source["address"];
|
||||
}
|
||||
}
|
||||
export class QSLBulkUpdate {
|
||||
sent_status: string;
|
||||
rcvd_status: string;
|
||||
@@ -3279,6 +3379,7 @@ export namespace main {
|
||||
off_urls?: string[];
|
||||
on_pattern?: string;
|
||||
off_pattern?: string;
|
||||
insecure_tls?: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new StationDevice(source);
|
||||
@@ -3298,6 +3399,7 @@ export namespace main {
|
||||
this.off_urls = source["off_urls"];
|
||||
this.on_pattern = source["on_pattern"];
|
||||
this.off_pattern = source["off_pattern"];
|
||||
this.insecure_tls = source["insecure_tls"];
|
||||
}
|
||||
}
|
||||
export class StationRelay {
|
||||
@@ -4063,6 +4165,39 @@ export namespace pskr {
|
||||
|
||||
}
|
||||
|
||||
export namespace psu {
|
||||
|
||||
export class Status {
|
||||
connected: boolean;
|
||||
on: boolean;
|
||||
volts: number;
|
||||
amps: number;
|
||||
watts: number;
|
||||
set_volts: number;
|
||||
set_amps: number;
|
||||
protected: number;
|
||||
error?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Status(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.connected = source["connected"];
|
||||
this.on = source["on"];
|
||||
this.volts = source["volts"];
|
||||
this.amps = source["amps"];
|
||||
this.watts = source["watts"];
|
||||
this.set_volts = source["set_volts"];
|
||||
this.set_amps = source["set_amps"];
|
||||
this.protected = source["protected"];
|
||||
this.error = source["error"];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export namespace qslcard {
|
||||
|
||||
export class Bevel {
|
||||
@@ -4495,6 +4630,7 @@ export namespace qso {
|
||||
my_vucc_grids?: string;
|
||||
extras?: Record<string, string>;
|
||||
award_refs?: string;
|
||||
sync_uid?: string;
|
||||
// Go type: time
|
||||
created_at: any;
|
||||
// Go type: time
|
||||
@@ -4636,6 +4772,7 @@ export namespace qso {
|
||||
this.my_vucc_grids = source["my_vucc_grids"];
|
||||
this.extras = source["extras"];
|
||||
this.award_refs = source["award_refs"];
|
||||
this.sync_uid = source["sync_uid"];
|
||||
this.created_at = this.convertValues(source["created_at"], null);
|
||||
this.updated_at = this.convertValues(source["updated_at"], null);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Every user-visible string ships in BOTH languages. That is a project rule,
|
||||
// and until now it was only a rule: seventeen keys had drifted into English
|
||||
// only, among them the whole update panel and two DX-cluster settings, found by
|
||||
// a French operator photographing his own screen.
|
||||
//
|
||||
// A key present in one dictionary and not the other falls back to the key
|
||||
// itself, so the interface shows "clu.spotTtl" where a label belongs — or, as
|
||||
// here, the English text, which reads as deliberate and is not.
|
||||
func TestEveryStringIsInBothLanguages(t *testing.T) {
|
||||
src, err := os.ReadFile("frontend/src/lib/i18n.tsx")
|
||||
if err != nil {
|
||||
t.Fatalf("read i18n.tsx: %v", err)
|
||||
}
|
||||
en := dictKeys(t, string(src), "const en: Dict = {")
|
||||
fr := dictKeys(t, string(src), "const fr: Dict = {")
|
||||
|
||||
if len(en) == 0 || len(fr) == 0 {
|
||||
t.Fatal("one of the dictionaries came back empty — this test has stopped checking anything")
|
||||
}
|
||||
for k := range en {
|
||||
if !fr[k] {
|
||||
t.Errorf("%q has no French — a French operator sees the English string, or the key itself", k)
|
||||
}
|
||||
}
|
||||
for k := range fr {
|
||||
if !en[k] {
|
||||
t.Errorf("%q exists only in French — an English operator sees the key", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dictKeys collects the keys of one dictionary literal: from its opening line
|
||||
// to the closing brace in column 0.
|
||||
func dictKeys(t *testing.T, src, opening string) map[string]bool {
|
||||
t.Helper()
|
||||
i := strings.Index(src, opening)
|
||||
if i < 0 {
|
||||
t.Fatalf("%q not found — the dictionaries have been renamed and this test needs updating", opening)
|
||||
}
|
||||
body := src[i+len(opening):]
|
||||
if j := strings.Index(body, "\n};"); j >= 0 {
|
||||
body = body[:j]
|
||||
}
|
||||
keyRe := regexp.MustCompile(`'([a-zA-Z0-9_.]+)'\s*:`)
|
||||
out := map[string]bool{}
|
||||
for _, m := range keyRe.FindAllStringSubmatch(body, -1) {
|
||||
out[m[1]] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package adif
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// An import must never INVENT a routing method. The two "via" fields carry what
|
||||
// the file carries, and nothing at all when it carries nothing.
|
||||
//
|
||||
// This is worth pinning because the opposite is easy to reach for: a card that
|
||||
// was confirmed electronically is the common case, and defaulting to E would
|
||||
// quietly rewrite the operator's own record of how their cards actually
|
||||
// travelled. Where an E does show up after an import, it came from the source
|
||||
// file — Log4OM writes QSL_SENT_VIA:1>E on every record whether or not a card
|
||||
// was ever sent, which is pinned by TestQSLSentViaDoesNotBecomeManager.
|
||||
func TestImportInventsNoQSLRouting(t *testing.T) {
|
||||
for name, rec := range map[string]string{
|
||||
"absent": "<CALL:5>OE6CLD<QSO_DATE:8>20260606<TIME_ON:4>1200<BAND:3>20m<MODE:2>CW<EOR>\n",
|
||||
"empty": "<CALL:5>OE6CLD<QSO_DATE:8>20260606<TIME_ON:4>1200<BAND:3>20m<MODE:2>CW<QSL_SENT_VIA:0><QSL_RCVD_VIA:0><EOR>\n",
|
||||
"whitespace": "<CALL:5>OE6CLD<QSO_DATE:8>20260606<TIME_ON:4>1200<BAND:3>20m<MODE:2>CW<QSL_SENT_VIA:1> <QSL_RCVD_VIA:1> <EOR>\n",
|
||||
// Not in the enumeration: dropped rather than passed through, or the
|
||||
// export would write a value no other logger can read.
|
||||
"unknown": "<CALL:5>OE6CLD<QSO_DATE:8>20260606<TIME_ON:4>1200<BAND:3>20m<MODE:2>CW<QSL_SENT_VIA:3>ZZZ<EOR>\n",
|
||||
} {
|
||||
var got Record
|
||||
if err := Parse(strings.NewReader("<EOH>\n"+rec), func(r Record) error { got = r; return nil }); err != nil {
|
||||
t.Fatalf("%s: parse: %v", name, err)
|
||||
}
|
||||
q, ok := recordToQSO(got)
|
||||
if !ok {
|
||||
t.Fatalf("%s: recordToQSO returned !ok", name)
|
||||
}
|
||||
if q.QSLSentVia != "" {
|
||||
t.Errorf("%s: QSL_SENT_VIA = %q, want empty — the import made up a routing method", name, q.QSLSentVia)
|
||||
}
|
||||
if q.QSLRcvdVia != "" {
|
||||
t.Errorf("%s: QSL_RCVD_VIA = %q, want empty — the import made up a routing method", name, q.QSLRcvdVia)
|
||||
}
|
||||
}
|
||||
}
|
||||
+145
-144
@@ -46,11 +46,12 @@
|
||||
"qsl"
|
||||
],
|
||||
"total": 0,
|
||||
"builtin": true
|
||||
"builtin": true,
|
||||
"version": 2
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"code": "1",
|
||||
"code": "01",
|
||||
"name": "Hokkaido",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
@@ -58,16 +59,81 @@
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "10",
|
||||
"name": "Gunma",
|
||||
"code": "02",
|
||||
"name": "Aomori",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "03",
|
||||
"name": "Iwate",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "04",
|
||||
"name": "Akita",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "05",
|
||||
"name": "Yamagata",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "06",
|
||||
"name": "Miyagi",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "07",
|
||||
"name": "Fukushima",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "08",
|
||||
"name": "Niigata",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "09",
|
||||
"name": "Nagano",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "10",
|
||||
"name": "Tokyo",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"pattern": "\\bTok[iy]o\\b",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "11",
|
||||
"name": "Saitama",
|
||||
"name": "Kanagawa",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
@@ -83,16 +149,15 @@
|
||||
},
|
||||
{
|
||||
"code": "13",
|
||||
"name": "Tokyo",
|
||||
"name": "Saitama",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"pattern": "\\bTok[iy]o\\b",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "14",
|
||||
"name": "Kanagawa",
|
||||
"name": "Ibaraki",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
@@ -100,7 +165,7 @@
|
||||
},
|
||||
{
|
||||
"code": "15",
|
||||
"name": "Niigata",
|
||||
"name": "Tochigi",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
@@ -108,7 +173,7 @@
|
||||
},
|
||||
{
|
||||
"code": "16",
|
||||
"name": "Toyama",
|
||||
"name": "Gunma",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
@@ -116,22 +181,6 @@
|
||||
},
|
||||
{
|
||||
"code": "17",
|
||||
"name": "Ishikawa",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "18",
|
||||
"name": "Fukui",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "19",
|
||||
"name": "Yamanashi",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
@@ -139,31 +188,7 @@
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "2",
|
||||
"name": "Aomori",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "20",
|
||||
"name": "Nagano",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "21",
|
||||
"name": "Gifu",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "22",
|
||||
"code": "18",
|
||||
"name": "Shizuoka",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
@@ -171,7 +196,15 @@
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "23",
|
||||
"code": "19",
|
||||
"name": "Gifu",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "20",
|
||||
"name": "Aichi",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
@@ -179,7 +212,7 @@
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "24",
|
||||
"code": "21",
|
||||
"name": "Mie",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
@@ -187,15 +220,7 @@
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "25",
|
||||
"name": "Shiga",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "26",
|
||||
"code": "22",
|
||||
"name": "Kyoto",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
@@ -203,23 +228,15 @@
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "27",
|
||||
"name": "Osaka",
|
||||
"code": "23",
|
||||
"name": "Shiga",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "28",
|
||||
"name": "Hyogo",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "29",
|
||||
"code": "24",
|
||||
"name": "Nara",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
@@ -227,24 +244,56 @@
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "3",
|
||||
"name": "Iwate",
|
||||
"code": "25",
|
||||
"name": "Osaka",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "30",
|
||||
"code": "26",
|
||||
"name": "Wakayama",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "27",
|
||||
"name": "Hyogo",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "28",
|
||||
"name": "Toyama",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "29",
|
||||
"name": "Fukui",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "30",
|
||||
"name": "Ishikawa",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "31",
|
||||
"name": "Tottori",
|
||||
"name": "Okayama",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
@@ -260,31 +309,31 @@
|
||||
},
|
||||
{
|
||||
"code": "33",
|
||||
"name": "Okayama",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "34",
|
||||
"name": "Hiroshima",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "35",
|
||||
"name": "Yamaguchi",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "34",
|
||||
"name": "Tottori",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "35",
|
||||
"name": "Hiroshima",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "36",
|
||||
"name": "Tokushima",
|
||||
"name": "Kagawa",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
@@ -292,7 +341,7 @@
|
||||
},
|
||||
{
|
||||
"code": "37",
|
||||
"name": "Kagawa",
|
||||
"name": "Tokushima",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
@@ -314,14 +363,6 @@
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "4",
|
||||
"name": "Miyagi",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "40",
|
||||
"name": "Fukuoka",
|
||||
@@ -385,48 +426,8 @@
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "5",
|
||||
"name": "Akita",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "6",
|
||||
"name": "Yamagata",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "7",
|
||||
"name": "Fukushima",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "8",
|
||||
"name": "Ibaraki",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "9",
|
||||
"name": "Tochigi",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package award
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// WAJA is numbered by the JARL, and the numbering is NOT Japan's ordinary
|
||||
// prefecture code.
|
||||
//
|
||||
// The catalog shipped with the government's JIS numbering instead — 01
|
||||
// Hokkaido, 02 Aomori, 03 Iwate, 04 Miyagi… — which agrees with the JARL's for
|
||||
// the first three prefectures and then diverges for thirty-five of the
|
||||
// remaining forty-four. The names were right throughout, so the award still
|
||||
// counted the right contacts; every reference simply carried the wrong number,
|
||||
// which is what an operator sends to the JARL when they claim it.
|
||||
//
|
||||
// The two schemes agree often enough to look correct at a glance, so this pins
|
||||
// the places they differ rather than a count. Each pair below is one the old
|
||||
// list got wrong, and the comment is what the old list said.
|
||||
func TestCatalogWAJAUsesTheJARLNumbering(t *testing.T) {
|
||||
raw, ok := CatalogRefs("WAJA")
|
||||
if !ok {
|
||||
t.Fatal("WAJA has no reference list in the embedded catalog")
|
||||
}
|
||||
var refs []struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Pattern string `json:"pattern"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &refs); err != nil {
|
||||
t.Fatalf("WAJA references: %v", err)
|
||||
}
|
||||
if len(refs) != 47 {
|
||||
t.Fatalf("WAJA has %d prefectures, want exactly 47", len(refs))
|
||||
}
|
||||
|
||||
byName := map[string]string{}
|
||||
byCode := map[string]string{}
|
||||
for _, r := range refs {
|
||||
byName[r.Name] = r.Code
|
||||
if prev, dup := byCode[r.Code]; dup {
|
||||
t.Errorf("number %s is on both %s and %s", r.Code, prev, r.Name)
|
||||
}
|
||||
byCode[r.Code] = r.Name
|
||||
}
|
||||
|
||||
for _, c := range []struct{ name, code string }{
|
||||
{"Hokkaido", "01"}, // the one both schemes agree on, and the anchor
|
||||
{"Miyagi", "06"}, // was 04
|
||||
{"Akita", "04"}, // was 05
|
||||
{"Niigata", "08"}, // was 15 — the JIS number
|
||||
{"Nagano", "09"}, // was 20
|
||||
{"Tokyo", "10"}, // was 13, the JIS number everyone recognises
|
||||
{"Kanagawa", "11"}, // was 14
|
||||
{"Saitama", "13"}, // was 11
|
||||
{"Ibaraki", "14"}, // was 8
|
||||
{"Gunma", "16"}, // was 10
|
||||
{"Yamanashi", "17"}, // was 19
|
||||
{"Kyoto", "22"}, // was 26
|
||||
{"Osaka", "25"}, // was 27
|
||||
{"Toyama", "28"}, // was 16
|
||||
{"Ishikawa", "30"}, // was 17
|
||||
{"Okayama", "31"}, // was 33
|
||||
{"Tottori", "34"}, // was 31
|
||||
{"Kagawa", "36"}, // was 37
|
||||
{"Tokushima", "37"}, // was 36
|
||||
{"Okinawa", "47"}, // unchanged: the far end of the list was already right
|
||||
} {
|
||||
if got := byName[c.name]; got != c.code {
|
||||
t.Errorf("%s is numbered %q, want %q on the JARL list", c.name, got, c.code)
|
||||
}
|
||||
}
|
||||
|
||||
// Two digits throughout, as the JARL prints them. Not cosmetic: the codes
|
||||
// are strings, so "1" sorts between "09" and "10" and the panel showed the
|
||||
// prefectures in an order no list anywhere uses.
|
||||
for _, r := range refs {
|
||||
if len(r.Code) != 2 {
|
||||
t.Errorf("%s is numbered %q — the JARL list is two digits throughout", r.Name, r.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// The Tokyo spelling rule has to sit on Tokyo, and Tokyo moved. Left behind
|
||||
// on the old number it would be matching QTHs for Saitama.
|
||||
for _, r := range refs {
|
||||
if r.Pattern == "" {
|
||||
continue
|
||||
}
|
||||
if r.Name != "Tokyo" {
|
||||
t.Errorf("%s (%s) carries the pattern %q, which belongs to Tokyo", r.Name, r.Code, r.Pattern)
|
||||
}
|
||||
}
|
||||
if byName["Tokyo"] != "" {
|
||||
for _, r := range refs {
|
||||
if r.Name == "Tokyo" && r.Pattern == "" {
|
||||
t.Error("Tokyo lost its spelling pattern in the renumbering — Tokio would stop counting")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -266,6 +266,48 @@ func (r *Repo) Upsert(ctx context.Context, awardCode string, ref Ref) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Rename changes a reference's CODE, keeping everything else about it.
|
||||
//
|
||||
// Wanted because a shipped list can simply be wrong: WAJA went out numbered by
|
||||
// the Japanese state instead of by the JARL, and the only way to correct it was
|
||||
// to delete all 47 references and import a new list — losing anything the
|
||||
// operator had adjusted. The number is the one field an editor could not touch.
|
||||
//
|
||||
// A rename, not a delete plus an insert: everything the reference carries — its
|
||||
// pattern, its DXCC list, its validity window — travels with it, which is the
|
||||
// whole point of correcting a number rather than replacing an entry.
|
||||
func (r *Repo) Rename(ctx context.Context, awardCode, oldCode, newCode string) error {
|
||||
ac := strings.ToUpper(strings.TrimSpace(awardCode))
|
||||
from := strings.ToUpper(strings.TrimSpace(oldCode))
|
||||
to := strings.ToUpper(strings.TrimSpace(newCode))
|
||||
if ac == "" || from == "" || to == "" {
|
||||
return fmt.Errorf("empty award or reference code")
|
||||
}
|
||||
if from == to {
|
||||
return nil
|
||||
}
|
||||
// A collision would REPLACE the other reference and take its name, pattern
|
||||
// and dates with it — one silently swallowing another, discovered much later
|
||||
// as a reference that has quietly gone missing.
|
||||
var n int
|
||||
if err := r.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM award_references WHERE award_code = ? AND ref_code = ?`, ac, to).Scan(&n); err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
return fmt.Errorf("%s already has a reference %s", ac, to)
|
||||
}
|
||||
res, err := r.db.ExecContext(ctx,
|
||||
`UPDATE award_references SET ref_code = ? WHERE award_code = ? AND ref_code = ?`, to, ac, from)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows, _ := res.RowsAffected(); rows == 0 {
|
||||
return fmt.Errorf("%s has no reference %s", ac, from)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes one reference from an award.
|
||||
func (r *Repo) Delete(ctx context.Context, awardCode, refCode string) error {
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package awardref
|
||||
|
||||
// One-IOTA DXCC entities: a table of the entities that ARE a single IOTA group.
|
||||
//
|
||||
// WHY IT EXISTS. QRZ.com carries <iota> for an operator who fills it in, and
|
||||
// most do not. But for a great many entities the island reference follows from
|
||||
// the entity alone — a station in Ascension Island is on AF-003, there is
|
||||
// nothing else it could be — and the entity is known for every callsign, from
|
||||
// cty.dat, without any callbook at all. So the reference can be filled for an
|
||||
// operator with no QRZ subscription, on a station that has never touched a
|
||||
// callbook, before the contact is logged.
|
||||
//
|
||||
// Only entities that map to EXACTLY ONE reference are here. France is not: a
|
||||
// French station is usually on the mainland and on no island at all, and
|
||||
// guessing would put a reference on hundreds of contacts that earn none.
|
||||
//
|
||||
// SOURCE: dxcc_matches_one_iota.json from the IOTA programme
|
||||
// (www.iota-world.org/islands-on-the-air/downloads/), fetched 2026-08-17,
|
||||
// 99 entities. It changes only when an entity appears or IOTA re-maps one, so
|
||||
// it is a table here rather than a download: it then works offline, which is
|
||||
// where a portable station usually is. To refresh, fetch that file again and
|
||||
// re-emit this map, sorted by entity number.
|
||||
//
|
||||
// Entity names are comments only, joined from internal/dxcc for readability.
|
||||
var iotaByDXCC = map[int]string{
|
||||
5: "EU-002", // Aland Islands
|
||||
10: "AF-002", // Amsterdam & St. Paul Is.
|
||||
12: "NA-022", // Anguilla
|
||||
17: "NA-020", // Aves Island
|
||||
20: "OC-089", // Baker & Howland Islands
|
||||
21: "EU-004", // Balearic Islands
|
||||
24: "AN-002", // Bouvet
|
||||
29: "AF-004", // Canary Islands
|
||||
34: "OC-038", // Chatham Islands
|
||||
35: "OC-002", // Christmas Island
|
||||
36: "NA-011", // Clipperton Island
|
||||
37: "NA-012", // Cocos Island
|
||||
38: "OC-003", // Cocos (Keeling) Islands
|
||||
41: "AF-008", // Crozet Island
|
||||
43: "NA-095", // Desecheo Island
|
||||
45: "EU-001", // Dodecanese
|
||||
62: "NA-021", // Barbados
|
||||
64: "NA-005", // Bermuda
|
||||
65: "NA-023", // British Virgin Islands
|
||||
69: "NA-016", // Cayman Islands
|
||||
71: "SA-004", // Galapagos Islands
|
||||
82: "NA-097", // Jamaica
|
||||
84: "NA-107", // Martinique
|
||||
91: "SA-036", // Aruba
|
||||
94: "NA-100", // Antigua & Barbuda
|
||||
95: "NA-101", // Dominica
|
||||
96: "NA-103", // Montserrat
|
||||
97: "NA-108", // St. Lucia
|
||||
99: "AF-011", // Glorioso Islands
|
||||
103: "OC-026", // Guam
|
||||
105: "NA-015", // Guantanamo Bay
|
||||
106: "EU-114", // Guernsey
|
||||
111: "AN-003", // Heard Island
|
||||
114: "EU-116", // Isle Of Man
|
||||
118: "EU-022", // Jan Mayen
|
||||
123: "OC-023", // Johnston Island
|
||||
131: "AF-048", // Kerguelen Islands
|
||||
133: "OC-039", // Kermadec Islands
|
||||
138: "OC-020", // Kure Island
|
||||
141: "SA-002", // Falkland Islands
|
||||
147: "OC-004", // Lord Howe Island
|
||||
153: "AN-005", // Macquarie Island
|
||||
157: "OC-031", // Nauru
|
||||
159: "AS-013", // Maldives
|
||||
161: "SA-007", // Malpelo Island
|
||||
165: "AF-049", // Mauritius
|
||||
166: "OC-086", // Mariana Islands
|
||||
167: "EU-053", // Market Reef
|
||||
169: "AF-027", // Mayotte
|
||||
171: "OC-072", // Mellish Reef
|
||||
174: "OC-030", // Midway Island
|
||||
177: "OC-073", // Minami Torishima
|
||||
182: "NA-098", // Navassa Island
|
||||
188: "OC-040", // Niue
|
||||
189: "OC-005", // Norfolk Island
|
||||
190: "OC-097", // Samoa
|
||||
195: "AF-039", // Annobon Island
|
||||
199: "AN-004", // Peter 1 Island
|
||||
201: "AF-021", // Pr. Edward & Marion Is.
|
||||
205: "AF-003", // Ascension Island
|
||||
207: "AF-017", // Rodriguez Island
|
||||
211: "NA-063", // Sable Island
|
||||
217: "SA-013", // San Felix & San Ambrosio
|
||||
222: "EU-018", // Faroe Islands
|
||||
238: "AN-008", // South Orkney Islands
|
||||
240: "AN-009", // South Sandwich Islands
|
||||
241: "AN-010", // South Shetland Islands
|
||||
247: "AS-051", // Spratly Islands
|
||||
249: "NA-104", // St. Kitts & Nevis
|
||||
250: "AF-022", // St. Helena
|
||||
252: "NA-094", // St. Paul Island
|
||||
253: "SA-014", // St. Peter & St. Paul
|
||||
257: "EU-023", // Malta
|
||||
270: "OC-048", // Tokelau Islands
|
||||
273: "SA-010", // Trindade & Martim Vaz
|
||||
276: "AF-031", // Tromelin Island
|
||||
277: "NA-032", // St. Pierre & Miquelon
|
||||
282: "OC-015", // Tuvalu
|
||||
283: "AS-004", // Uk Base Areas On Cyprus
|
||||
285: "NA-106", // Us Virgin Islands
|
||||
297: "OC-053", // Wake Island
|
||||
301: "OC-017", // Western Kiribati
|
||||
303: "OC-007", // Willis Island
|
||||
381: "AS-019", // Singapore
|
||||
411: "AF-007", // Comoros
|
||||
453: "AF-016", // Reunion Island
|
||||
460: "OC-060", // Rotuma Island
|
||||
489: "OC-112", // Conway Reef
|
||||
490: "OC-018", // Banaba Island
|
||||
505: "AS-110", // Pratas Island
|
||||
506: "AS-116", // Scarborough Reef
|
||||
509: "OC-027", // Marquesas Islands
|
||||
512: "OC-176", // Chesterfield Islands
|
||||
513: "OC-182", // Ducie Island
|
||||
515: "OC-200", // Swains Island
|
||||
516: "NA-146", // St. Barthelemy
|
||||
517: "SA-099", // Curacao
|
||||
519: "NA-145", // Saba & St. Eustatius
|
||||
520: "SA-006", // Bonaire
|
||||
}
|
||||
|
||||
// IOTAForDXCC returns the island reference of a DXCC entity that is a single
|
||||
// IOTA group, or "" for an entity that holds several islands or none.
|
||||
func IOTAForDXCC(dxcc int) string { return iotaByDXCC[dxcc] }
|
||||
@@ -0,0 +1,51 @@
|
||||
package awardref
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The entities an operator meets: an island group that is its own DXCC, and a
|
||||
// mainland country that is not.
|
||||
func TestIOTAForDXCC(t *testing.T) {
|
||||
cases := []struct {
|
||||
dxcc int
|
||||
want string
|
||||
why string
|
||||
}{
|
||||
{205, "AF-003", "Ascension Island is one island and one reference"},
|
||||
{5, "EU-002", "Aland Islands"},
|
||||
{12, "NA-022", "Anguilla"},
|
||||
{24, "AN-002", "Bouvet — the entity whose entry is checked most often and worked least"},
|
||||
// France holds hundreds of islands and, far more to the point, a
|
||||
// mainland. Filling a reference here would earn nothing and would put
|
||||
// one on nearly every European contact in the log.
|
||||
{227, "", "France is not one IOTA"},
|
||||
{291, "", "the United States is not one IOTA"},
|
||||
{0, "", "no entity resolved"},
|
||||
{99999, "", "not an entity at all"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := IOTAForDXCC(c.dxcc); got != c.want {
|
||||
t.Errorf("IOTAForDXCC(%d) = %q, want %q — %s", c.dxcc, got, c.want, c.why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every reference in the table must be a well-formed IOTA reference, because
|
||||
// one that is not would be written onto a contact's award references and count
|
||||
// for nothing — and would then have to be found by hand, one QSO at a time.
|
||||
func TestEveryOneIOTAEntryIsWellFormed(t *testing.T) {
|
||||
ref := regexp.MustCompile(`^(AF|AN|AS|EU|NA|OC|SA)-\d{3}$`)
|
||||
if len(iotaByDXCC) < 50 {
|
||||
t.Fatalf("the table holds %d entities — it has been truncated", len(iotaByDXCC))
|
||||
}
|
||||
for dxcc, r := range iotaByDXCC {
|
||||
if dxcc <= 0 {
|
||||
t.Errorf("entity number %d is not a DXCC entity", dxcc)
|
||||
}
|
||||
if !ref.MatchString(r) {
|
||||
t.Errorf("entity %d maps to %q, which is not an IOTA reference", dxcc, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package awardref
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"hamlog/internal/db"
|
||||
)
|
||||
|
||||
func renameRepo(t *testing.T) *Repo {
|
||||
t.Helper()
|
||||
conn, err := db.Open(filepath.Join(t.TempDir(), "a.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { conn.Close() })
|
||||
return NewRepo(conn)
|
||||
}
|
||||
|
||||
// Correcting a reference's number must keep the reference.
|
||||
//
|
||||
// WAJA shipped numbered by the Japanese state instead of by the JARL, and until
|
||||
// now the only way to fix that was to delete all 47 references and import a new
|
||||
// list — losing anything the operator had adjusted. A rename keeps the pattern,
|
||||
// the entity list and the validity window, because a wrong NUMBER is all that
|
||||
// was wrong.
|
||||
func TestRenameKeepsEverythingButTheCode(t *testing.T) {
|
||||
r := renameRepo(t)
|
||||
ctx := context.Background()
|
||||
if err := r.Upsert(ctx, "WAJA", Ref{
|
||||
Code: "13", Name: "Tokyo", Pattern: `\bTok[iy]o\b`, Valid: true,
|
||||
DXCCList: []int{339}, ValidFrom: "1970-01-01",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
if err := r.Rename(ctx, "WAJA", "13", "10"); err != nil {
|
||||
t.Fatalf("rename: %v", err)
|
||||
}
|
||||
|
||||
refs, err := r.List(ctx, "WAJA")
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(refs) != 1 {
|
||||
t.Fatalf("WAJA holds %d references after a rename, want 1 — it was copied, not renamed", len(refs))
|
||||
}
|
||||
got := refs[0]
|
||||
if got.Code != "10" {
|
||||
t.Errorf("code = %q, want 10", got.Code)
|
||||
}
|
||||
if got.Name != "Tokyo" || got.Pattern != `\bTok[iy]o\b` {
|
||||
t.Errorf("the reference lost what it carried: name=%q pattern=%q", got.Name, got.Pattern)
|
||||
}
|
||||
if len(got.DXCCList) != 1 || got.DXCCList[0] != 339 || got.ValidFrom != "1970-01-01" {
|
||||
t.Errorf("the reference lost its entity list or dates: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A number already in use must be refused. Left to REPLACE, the rename would
|
||||
// take the other reference's name, pattern and dates with it — one entry
|
||||
// silently swallowing another, found much later as a prefecture that has
|
||||
// quietly gone missing from the list.
|
||||
func TestRenameRefusesANumberAlreadyTaken(t *testing.T) {
|
||||
r := renameRepo(t)
|
||||
ctx := context.Background()
|
||||
if err := r.Upsert(ctx, "WAJA", Ref{Code: "10", Name: "Gunma", Valid: true}); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if err := r.Upsert(ctx, "WAJA", Ref{Code: "13", Name: "Tokyo", Valid: true}); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
if err := r.Rename(ctx, "WAJA", "13", "10"); err == nil {
|
||||
t.Fatal("renaming onto an existing number was accepted — one reference would have eaten the other")
|
||||
}
|
||||
refs, _ := r.List(ctx, "WAJA")
|
||||
if len(refs) != 2 {
|
||||
t.Fatalf("WAJA holds %d references, want both still there", len(refs))
|
||||
}
|
||||
}
|
||||
|
||||
// Renaming something that is not there is an error, not a silent no-op: it
|
||||
// means the editor and the store disagree about what the award holds.
|
||||
func TestRenameAnUnknownReferenceFails(t *testing.T) {
|
||||
r := renameRepo(t)
|
||||
if err := r.Rename(context.Background(), "WAJA", "99", "10"); err == nil {
|
||||
t.Error("renaming a reference the award does not have was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// Codes are stored upper-cased, so a rename must compare the same way — else
|
||||
// "eu-048" onto "EU-048" looks like a move and is really the same reference,
|
||||
// which the collision check has to catch.
|
||||
func TestRenameIsCaseInsensitive(t *testing.T) {
|
||||
r := renameRepo(t)
|
||||
ctx := context.Background()
|
||||
if err := r.Upsert(ctx, "IOTA", Ref{Code: "EU-048", Name: "Belle-Ile", Valid: true}); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if err := r.Rename(ctx, "iota", "eu-048", "eu-048"); err != nil {
|
||||
t.Errorf("renaming a reference to itself in another case failed: %v", err)
|
||||
}
|
||||
if err := r.Rename(ctx, "IOTA", "eu-048", "eu-049"); err != nil {
|
||||
t.Fatalf("rename: %v", err)
|
||||
}
|
||||
refs, _ := r.List(ctx, "IOTA")
|
||||
if len(refs) != 1 || refs[0].Code != "EU-049" {
|
||||
t.Errorf("references = %+v, want the one renamed to EU-049 and upper-cased", refs)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package cat
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -535,11 +536,36 @@ func (b *IcomSerial) SetMode(mode string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// errIcomAckLost is "the reply never came" — as opposed to a reply that said no
|
||||
// (NG) or a dead port. Only this one is worth repeating: the command may well
|
||||
// have been carried out and only its acknowledgement lost. Sentinel rather than
|
||||
// a formatted string so callers can tell the two apart.
|
||||
var errIcomAckLost = errors.New("icom: timeout waiting for response")
|
||||
|
||||
// SetPTT keys or unkeys the transmitter (CI-V 0x1C 0x00), retrying ONCE when the
|
||||
// acknowledgement is lost.
|
||||
//
|
||||
// A missing FB is not a missing command — the rig acts on the frame as soon as it
|
||||
// decodes it, and what expires is our wait for the answer on a bus shared with
|
||||
// the rig's own transceive updates. JTDX in "Split Operating: Fake It" moves the
|
||||
// dial immediately before every key-down, so the PTT ack queues behind that
|
||||
// traffic, and one lost ack was fatal: rigctld answered RPRT -9, JTDX read that
|
||||
// as losing rig control and tore the connection down mid-over, reopening it a
|
||||
// moment later (an operator's log shows exactly that, twice, a new rigctld client
|
||||
// within 300 ms of each failure). The same session over TCI never failed, because
|
||||
// TCI carries no CI-V and needs no Fake It.
|
||||
//
|
||||
// Re-sending is safe: asking for a state the rig is already in changes nothing.
|
||||
func (b *IcomSerial) SetPTT(on bool) error {
|
||||
state := byte(0)
|
||||
if on {
|
||||
state = 1
|
||||
}
|
||||
err := b.exec(civ.CmdPTT, civ.SubPTT, state)
|
||||
if err == nil || !errors.Is(err, errIcomAckLost) {
|
||||
return err
|
||||
}
|
||||
applog.Printf("icom: PTT %v — no acknowledgement in %s, sending it once more", on, icomCmdTimeout)
|
||||
return b.exec(civ.CmdPTT, civ.SubPTT, state)
|
||||
}
|
||||
|
||||
@@ -619,7 +645,7 @@ func (b *IcomSerial) recv(timeout time.Duration, match func(civ.Decoded) bool) (
|
||||
case <-cancel:
|
||||
return civ.Decoded{}, fmt.Errorf("icom: interrupted")
|
||||
case <-deadline:
|
||||
return civ.Decoded{}, fmt.Errorf("icom: timeout waiting for response")
|
||||
return civ.Decoded{}, errIcomAckLost
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+116
-2
@@ -46,8 +46,33 @@ type TCI struct {
|
||||
mode string
|
||||
split bool
|
||||
tx bool
|
||||
// txAllowed is what the radio last said about TRANSMIT PERMISSION.
|
||||
//
|
||||
// TX_ENABLE is sent by ExpertSDR when a client connects and again whenever
|
||||
// the band changes, "in case transmitter permission was changed" (§4.3). When
|
||||
// it is false the radio silently ignores TRX — which is exactly what an
|
||||
// operator sees as "PTT does nothing", with no error anywhere to explain it.
|
||||
//
|
||||
// txAllowedKnown keeps an OLDER ExpertSDR, or a TCI-compatible program that
|
||||
// never sends TX_ENABLE at all, from being treated as refusing: without a
|
||||
// word from the radio we key and let it decide.
|
||||
txAllowed bool
|
||||
txAllowedKnown bool
|
||||
|
||||
lastSig string // last logged state signature (log only on change)
|
||||
|
||||
// spotFreq is the frequency of the marker currently on the panorama for each
|
||||
// callsign — the panadapter's own state, which TCI never reports back. It is
|
||||
// what makes one spot per call possible: without it there is no way to know
|
||||
// there is an older marker to delete.
|
||||
spotFreq map[string]int64
|
||||
}
|
||||
|
||||
func absInt64(v int64) int64 {
|
||||
if v < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
const tciDefaultPort = 40001
|
||||
@@ -96,18 +121,73 @@ func (t *TCI) Connect() error {
|
||||
t.mu.Lock()
|
||||
t.conn = conn
|
||||
t.ready = false
|
||||
// Forget the previous session's transmit permission: the radio announces it
|
||||
// again on connect, and a refusal remembered from a band we have since left
|
||||
// would block PTT until it did.
|
||||
t.txAllowed, t.txAllowedKnown = false, false
|
||||
t.mu.Unlock()
|
||||
debugLog.Printf("TCI: connected to %s", url)
|
||||
go t.reader(conn)
|
||||
if t.spotsEnabled {
|
||||
// Forget what we thought was on the panorama at the same moment the radio
|
||||
// is told to drop it. Kept, the memory would suppress the next spot for
|
||||
// each of those calls as "already drawn" onto a panorama now empty.
|
||||
t.mu.Lock()
|
||||
t.spotFreq = map[string]int64{}
|
||||
t.mu.Unlock()
|
||||
_ = t.send("spot_clear;") // drop any leftover spots from a previous session
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// spotFreqTolHz is how far a re-spot of the same callsign may sit from the one
|
||||
// already on the panorama before it is treated as a move rather than the same
|
||||
// spot said again.
|
||||
//
|
||||
// Two spotters hearing the same CW station rarely agree to better than a couple
|
||||
// of hundred hertz, and every one of them produces a cluster line. Below this
|
||||
// they are the same spot and nothing is sent at all; above it the marker is
|
||||
// deleted and redrawn where the station now is.
|
||||
const spotFreqTolHz = 500
|
||||
|
||||
// noteSpot records what the panorama is about to hold for a callsign and says
|
||||
// what has to be sent: whether to draw at all, and whether an older marker for
|
||||
// the same call must be deleted first.
|
||||
//
|
||||
// Separate from SendSpot so the rule can be tested without a radio — and
|
||||
// because the lock must be released before anything is sent: t.send takes t.mu
|
||||
// itself, Go mutexes are not reentrant, and sending while holding it would
|
||||
// deadlock the backend and take the rig offline.
|
||||
func (t *TCI) noteSpot(call string, freqHz int64) (draw, deletePrev bool) {
|
||||
key := strings.ToUpper(strings.TrimSpace(call))
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
prev, had := t.spotFreq[key]
|
||||
if had && absInt64(prev-freqHz) <= spotFreqTolHz {
|
||||
return false, false
|
||||
}
|
||||
if t.spotFreq == nil {
|
||||
t.spotFreq = map[string]int64{}
|
||||
}
|
||||
if len(t.spotFreq) > 4000 {
|
||||
t.spotFreq = map[string]int64{} // bound memory on a long session
|
||||
had = false // forgotten: nothing left to delete by name
|
||||
}
|
||||
t.spotFreq[key] = freqHz
|
||||
return true, had
|
||||
}
|
||||
|
||||
// SendSpot mirrors a cluster spot onto the TCI panorama (implements Spotter).
|
||||
// The radio replaces a spot that has the same callsign, so re-spotting updates
|
||||
// it in place. No-op when spot mirroring is disabled.
|
||||
// No-op when spot mirroring is disabled.
|
||||
//
|
||||
// ONE MARKER PER CALLSIGN. This code assumed the radio replaced a spot carrying
|
||||
// a callsign it already had; it does not. ExpertSDR keys a spot on its
|
||||
// frequency too, so a DX station spotted by three operators — 14025.00,
|
||||
// 14025.12, 14024.90, which is an ordinary minute on a cluster — was drawn
|
||||
// three times, a few pixels apart, and stayed that way.
|
||||
//
|
||||
// So the previous spot for the call is deleted before the new one is sent,
|
||||
// which is what the FlexRadio backend has always done (spot remove / spot add).
|
||||
func (t *TCI) SendSpot(s SpotInfo) error {
|
||||
if !t.spotsEnabled {
|
||||
return nil
|
||||
@@ -116,6 +196,17 @@ func (t *TCI) SendSpot(s SpotInfo) error {
|
||||
if call == "" || s.FreqHz <= 0 {
|
||||
return nil
|
||||
}
|
||||
draw, deletePrev := t.noteSpot(call, s.FreqHz)
|
||||
if !draw {
|
||||
return nil // the same station said again by another spotter
|
||||
}
|
||||
if deletePrev {
|
||||
// SPOT_DELETE takes the callsign alone. Not in the protocol PDF this
|
||||
// backend was written from; confirmed against ars-ka0s/eesdr-tci, which
|
||||
// lists SPOT (5 arguments), SPOT_DELETE (1) and SPOT_CLEAR (0) — the
|
||||
// other two matching what already works here.
|
||||
_ = t.send(fmt.Sprintf("spot_delete:%s;", call))
|
||||
}
|
||||
// TCI's SPOT command wants the colour as a signed 32-bit DECIMAL integer in
|
||||
// 0xAARRGGBB order — NOT a "0x…" hex string (e.g. "spot:UN7GK,cw,14025000,
|
||||
// -16776961,test;"). ExpertSDR silently drops a spot whose colour field it
|
||||
@@ -223,7 +314,22 @@ func (t *TCI) SetMode(mode string) error {
|
||||
}
|
||||
|
||||
// SetPTT keys or unkeys the transmitter (VFO 0).
|
||||
//
|
||||
// A refusal by the radio is reported rather than swallowed. ExpertSDR announces
|
||||
// transmit permission with TX_ENABLE and then simply IGNORES trx when it is
|
||||
// false — out-of-band frequency, TX disabled in the program, no PA. The command
|
||||
// went out, nothing happened, and nothing anywhere said why. Now the operator
|
||||
// is told, and the message names the place to look.
|
||||
func (t *TCI) SetPTT(on bool) error {
|
||||
if on {
|
||||
t.mu.Lock()
|
||||
known, allowed := t.txAllowedKnown, t.txAllowed
|
||||
t.mu.Unlock()
|
||||
if known && !allowed {
|
||||
return fmt.Errorf("the radio is refusing to transmit (TCI reports TX disabled) — " +
|
||||
"check the frequency is inside a transmit band and that TX is enabled in ExpertSDR")
|
||||
}
|
||||
}
|
||||
return t.send(fmt.Sprintf("trx:0,%t;", on))
|
||||
}
|
||||
|
||||
@@ -317,6 +423,14 @@ func (t *TCI) handle(msg string) {
|
||||
if get(0) == "0" {
|
||||
t.tx = get(1) == "true"
|
||||
}
|
||||
case "tx_enable":
|
||||
if get(0) == "0" {
|
||||
allowed := get(1) == "true"
|
||||
if !t.txAllowedKnown || t.txAllowed != allowed {
|
||||
debugLog.Printf("TCI: the radio %s transmitting", map[bool]string{true: "allows", false: "REFUSES"}[allowed])
|
||||
}
|
||||
t.txAllowed, t.txAllowedKnown = allowed, true
|
||||
}
|
||||
default:
|
||||
lname := strings.ToLower(name)
|
||||
// A click on one of our panorama spots comes back as
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
//go:build windows
|
||||
|
||||
package cat
|
||||
|
||||
import "testing"
|
||||
|
||||
// feed pushes messages at the backend the way the radio would.
|
||||
func feed(t *TCI, msgs ...string) {
|
||||
for _, m := range msgs {
|
||||
t.handle(m)
|
||||
}
|
||||
}
|
||||
|
||||
// "PTT via CAT does nothing on TCI."
|
||||
//
|
||||
// ExpertSDR announces transmit permission with TX_ENABLE — on connect, and
|
||||
// again whenever the band changes "in case transmitter permission was changed"
|
||||
// (§4.3 of the protocol document). When it is false the radio simply IGNORES
|
||||
// trx. OpsLog sent the documented command, the radio discarded it, and nothing
|
||||
// anywhere said why: the operator pressed a dead key.
|
||||
func TestPTTIsRefusedOutLoudWhenTheRadioForbidsTransmitting(t *testing.T) {
|
||||
tci := NewTCI("localhost", 40001, "FT8", false)
|
||||
feed(tci, "tx_enable:0,false")
|
||||
|
||||
err := tci.SetPTT(true)
|
||||
if err == nil {
|
||||
t.Fatal("keying was accepted while the radio forbids transmitting — the operator gets no reason at all")
|
||||
}
|
||||
// The message has to name where to look; "PTT failed" sends nobody anywhere.
|
||||
for _, want := range []string{"transmit", "ExpertSDR"} {
|
||||
if !contains(err.Error(), want) {
|
||||
t.Errorf("the refusal reads %q, which does not mention %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
// Unkeying is never blocked. Whatever the radio thinks about permission, a
|
||||
// request to STOP transmitting must always reach it.
|
||||
if err := tci.SetPTT(false); err != nil && contains(err.Error(), "refusing") {
|
||||
t.Errorf("unkeying was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Permission comes back when the operator returns to a band they may use, and
|
||||
// PTT has to come back with it — not stay blocked until OpsLog is restarted.
|
||||
func TestPermissionGrantedAgainRestoresPTT(t *testing.T) {
|
||||
tci := NewTCI("localhost", 40001, "FT8", false)
|
||||
feed(tci, "tx_enable:0,false")
|
||||
if err := tci.SetPTT(true); err == nil {
|
||||
t.Fatal("keying was accepted while forbidden")
|
||||
}
|
||||
feed(tci, "tx_enable:0,true")
|
||||
// No connection here, so the send fails — but it must fail as a TRANSPORT
|
||||
// error, never as a refusal.
|
||||
if err := tci.SetPTT(true); err != nil && contains(err.Error(), "refusing") {
|
||||
t.Errorf("still refusing after permission was granted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A radio that never mentions TX_ENABLE — an older ExpertSDR, or one of the
|
||||
// other programs that speak TCI — must not be treated as refusing. Silence is
|
||||
// not a "no": we key, and let the radio decide.
|
||||
func TestSilenceAboutPermissionIsNotARefusal(t *testing.T) {
|
||||
tci := NewTCI("localhost", 40001, "FT8", false)
|
||||
if err := tci.SetPTT(true); err != nil && contains(err.Error(), "refusing") {
|
||||
t.Errorf("a radio that never sent TX_ENABLE was treated as forbidding transmit: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
return len(sub) == 0 || (len(s) >= len(sub) && indexOf(s, sub) >= 0)
|
||||
}
|
||||
|
||||
func indexOf(s, sub string) int {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//go:build windows
|
||||
|
||||
package cat
|
||||
|
||||
import "testing"
|
||||
|
||||
// The reported symptom: with spot mirroring on, the same station appeared two
|
||||
// or three times on the panorama.
|
||||
//
|
||||
// Its cause is not in OpsLog's spot pipeline — one cluster line produces one
|
||||
// SendSpot. It is that a popular DX station IS spotted two or three times, by
|
||||
// different operators within the same minute, and no two of them agree on the
|
||||
// frequency to better than a few tens of hertz. The backend assumed ExpertSDR
|
||||
// replaced a spot bearing a callsign it already had; it keys on the frequency
|
||||
// too, so each of those became its own marker.
|
||||
func TestSameStationSpottedBySeveralOperatorsIsDrawnOnce(t *testing.T) {
|
||||
tci := &TCI{spotsEnabled: true}
|
||||
|
||||
draw, del := tci.noteSpot("UN7GK", 14025000)
|
||||
if !draw || del {
|
||||
t.Fatalf("first spot: draw=%v delete=%v, want draw and nothing to delete", draw, del)
|
||||
}
|
||||
// The same station, two more spotters, a few tens of hertz apart.
|
||||
for _, hz := range []int64{14025120, 14024900} {
|
||||
if draw, del := tci.noteSpot("UN7GK", hz); draw || del {
|
||||
t.Errorf("re-spot at %d Hz: draw=%v delete=%v, want nothing sent — this is the duplicate marker", hz, draw, del)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A station that really moves must still move on the panorama, and the marker
|
||||
// left where it was must go. Deleting first is the whole difference between
|
||||
// "the spot follows the station" and "the station collects markers".
|
||||
func TestAStationThatMovesReplacesItsMarker(t *testing.T) {
|
||||
tci := &TCI{spotsEnabled: true}
|
||||
tci.noteSpot("UN7GK", 14025000)
|
||||
|
||||
draw, del := tci.noteSpot("UN7GK", 14032000) // 7 kHz up: a real QSY
|
||||
if !draw || !del {
|
||||
t.Fatalf("after a QSY: draw=%v delete=%v, want the old marker deleted and a new one drawn", draw, del)
|
||||
}
|
||||
// And the new position becomes the reference, so spotters agreeing with it
|
||||
// are quiet again.
|
||||
if draw, _ := tci.noteSpot("UN7GK", 14032100); draw {
|
||||
t.Error("a spot at the station's new frequency was drawn again")
|
||||
}
|
||||
}
|
||||
|
||||
// Case matters nowhere in ham radio, and the cluster is not consistent about it.
|
||||
func TestSpotMemoryIgnoresCase(t *testing.T) {
|
||||
tci := &TCI{spotsEnabled: true}
|
||||
tci.noteSpot("un7gk", 14025000)
|
||||
if draw, _ := tci.noteSpot("UN7GK", 14025000); draw {
|
||||
t.Error("the same call in another case was treated as a different station")
|
||||
}
|
||||
}
|
||||
|
||||
// Two different stations are two markers — the whole point of the panorama.
|
||||
func TestDifferentStationsEachGetAMarker(t *testing.T) {
|
||||
tci := &TCI{spotsEnabled: true}
|
||||
tci.noteSpot("UN7GK", 14025000)
|
||||
draw, del := tci.noteSpot("ZD7BG", 14025050) // 50 Hz away, a different operator
|
||||
if !draw {
|
||||
t.Error("a second station near the first was swallowed as a duplicate")
|
||||
}
|
||||
if del {
|
||||
t.Error("deleting by callsign would have removed a spot this station never had")
|
||||
}
|
||||
}
|
||||
|
||||
// The connection drops and comes back: Connect sends spot_clear, so the
|
||||
// panorama is empty. If the memory survived that, the next spot for each of
|
||||
// those calls would be suppressed as "already drawn" onto an empty panorama —
|
||||
// the operator's spots would simply stop appearing until they changed
|
||||
// frequency.
|
||||
func TestReconnectingForgetsWhatWasDrawn(t *testing.T) {
|
||||
tci := &TCI{spotsEnabled: true}
|
||||
tci.noteSpot("UN7GK", 14025000)
|
||||
|
||||
tci.mu.Lock()
|
||||
tci.spotFreq = map[string]int64{} // what Connect does alongside spot_clear
|
||||
tci.mu.Unlock()
|
||||
|
||||
if draw, del := tci.noteSpot("UN7GK", 14025000); !draw || del {
|
||||
t.Errorf("after a reconnect: draw=%v delete=%v, want it drawn again and nothing deleted", draw, del)
|
||||
}
|
||||
}
|
||||
+162
-23
@@ -18,6 +18,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
@@ -355,6 +356,18 @@ func (s *session) run() {
|
||||
// that gets skipped.
|
||||
var idleTick = 30 * time.Second
|
||||
|
||||
// promptTick is the read deadline used until the login handshake is finished.
|
||||
// Short, because a node's "login:" / "password:" carries no newline and is only
|
||||
// visible when the read times out — see the read loop. It costs a few wake-ups
|
||||
// during the first seconds of a connection and nothing afterwards.
|
||||
const promptTick = 700 * time.Millisecond
|
||||
|
||||
// handshakeWindow bounds how long the fast promptTick applies. A node that has
|
||||
// a password configured but never asks for one would otherwise keep the loop
|
||||
// waking every 700 ms for the life of the connection, for a prompt that is never
|
||||
// coming. Any real login exchange is over in a second or two.
|
||||
const handshakeWindow = 20 * time.Second
|
||||
|
||||
// quietNotice is the silence after which the log says so, once.
|
||||
const quietNotice = 10 * time.Minute
|
||||
|
||||
@@ -369,10 +382,21 @@ func (s *session) runOnce() (time.Time, error) {
|
||||
// failure surfaces as an error on Read — which is the only thing that ends
|
||||
// a session below.
|
||||
d := net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
|
||||
// TIMED, and reported. An operator sees "connected" and no spots for a
|
||||
// minute on the first launch, then an instant connection when the program is
|
||||
// restarted — which is the signature of a slow name resolution rather than a
|
||||
// slow cluster (the OS caches the answer, so the second run skips it). The
|
||||
// only way to tell that from a node that simply had nothing to say is to
|
||||
// know how long the dial itself took.
|
||||
dialStart := time.Now()
|
||||
conn, err := d.Dial("tcp", addr)
|
||||
if err != nil {
|
||||
applog.Printf("cluster[%s] dial %s failed after %s: %v", s.cfg.Name, addr, time.Since(dialStart).Round(time.Millisecond), err)
|
||||
return time.Time{}, fmt.Errorf("dial %s: %w", addr, err)
|
||||
}
|
||||
applog.Printf("cluster[%s] connected to %s in %s", s.cfg.Name, addr, time.Since(dialStart).Round(time.Millisecond))
|
||||
linkUpAt := time.Now()
|
||||
firstSpotLogged := false
|
||||
s.mu.Lock()
|
||||
s.conn = conn
|
||||
s.mu.Unlock()
|
||||
@@ -387,16 +411,37 @@ func (s *session) runOnce() (time.Time, error) {
|
||||
|
||||
// Login: send on first prompt OR blindly after 1.5s. Many DXSpider
|
||||
// nodes accept the callsign without re-prompting.
|
||||
loginSent := false
|
||||
//
|
||||
// Atomic because the blind-login timer below and the read loop both touch
|
||||
// these. loginSent used to be a plain bool that the timer NEVER SET: the
|
||||
// callsign went out and nothing recorded it, so the password branch — gated on
|
||||
// loginSent — was dead code, and the session could only reach "connected" by
|
||||
// recognising a welcome banner. On a node whose entire greeting is a bare
|
||||
// "login:" and which then demands a password (f5mzn.org:9000), that left the
|
||||
// server stuck at "connecting" for ever while telnet logged in by hand fine.
|
||||
var loginSent, pwdSent atomic.Bool
|
||||
// CompareAndSwap, not a plain store: the timer and the loop can reach these at
|
||||
// the same moment, and the callsign must be written exactly once.
|
||||
sendLogin := func() {
|
||||
if s.login != "" && loginSent.CompareAndSwap(false, true) {
|
||||
_, _ = conn.Write([]byte(s.login + "\r\n"))
|
||||
}
|
||||
}
|
||||
// Sent ONCE per connection. A node that re-prompts is refusing the password,
|
||||
// and answering with the same one again only loops — better to let the
|
||||
// refusal show in the console than to hide it behind a retry.
|
||||
sendPassword := func() {
|
||||
if s.cfg.Password != "" && loginSent.Load() && pwdSent.CompareAndSwap(false, true) {
|
||||
_, _ = conn.Write([]byte(s.cfg.Password + "\r\n"))
|
||||
}
|
||||
}
|
||||
if s.login != "" {
|
||||
go func() {
|
||||
select {
|
||||
case <-s.stopCh:
|
||||
return
|
||||
case <-time.After(1500 * time.Millisecond):
|
||||
if !loginSent {
|
||||
_, _ = conn.Write([]byte(s.login + "\r\n"))
|
||||
}
|
||||
sendLogin()
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -442,7 +487,21 @@ func (s *session) runOnce() (time.Time, error) {
|
||||
|
||||
var connectedAt time.Time
|
||||
var quiet time.Duration // how long the node has said nothing
|
||||
var quietNoticed bool // the long-silence line is said once
|
||||
var pending string // a line cut in half by a read deadline
|
||||
markConnected := func() {
|
||||
if s.snapshot().State == StateConnected {
|
||||
return
|
||||
}
|
||||
connectedAt = time.Now()
|
||||
s.mu.Lock()
|
||||
s.status.State = StateConnected
|
||||
s.status.ConnectedAt = connectedAt
|
||||
s.status.Error = ""
|
||||
s.mu.Unlock()
|
||||
s.emitStatus()
|
||||
fireInitCommands()
|
||||
}
|
||||
rd := bufio.NewReader(conn)
|
||||
for {
|
||||
select {
|
||||
@@ -460,7 +519,21 @@ func (s *session) runOnce() (time.Time, error) {
|
||||
// Only a REAL error ends the session. A dead peer still gets caught:
|
||||
// TCP keepalive probes an idle connection and its failure arrives here
|
||||
// as an error, not as a timeout.
|
||||
_ = conn.SetReadDeadline(time.Now().Add(idleTick))
|
||||
// SHORT deadline until the handshake is done, the long idle tick after.
|
||||
//
|
||||
// A cluster writes its prompts WITHOUT a trailing newline, and ReadString
|
||||
// only returns on one — so a prompt is never a "line" at all, it is whatever
|
||||
// is sitting in the buffer when the read deadline expires. At the ordinary
|
||||
// 30 s tick that made a bare "login:" invisible for half a minute and a
|
||||
// following "password:" invisible for another, which is long enough for the
|
||||
// node to give up on us. Only the handshake needs the fast tick; once logged
|
||||
// in, a long deadline is exactly what we want (see idleTick).
|
||||
tick := idleTick
|
||||
if time.Since(linkUpAt) < handshakeWindow &&
|
||||
(!loginSent.Load() || (s.cfg.Password != "" && !pwdSent.Load())) {
|
||||
tick = promptTick
|
||||
}
|
||||
_ = conn.SetReadDeadline(time.Now().Add(tick))
|
||||
chunk, err := rd.ReadString('\n')
|
||||
if err != nil {
|
||||
var ne net.Error
|
||||
@@ -469,16 +542,48 @@ func (s *session) runOnce() (time.Time, error) {
|
||||
// Keep it: a spot line straddling the deadline would otherwise lose
|
||||
// its first half and arrive as nonsense, or vanish entirely.
|
||||
pending += chunk
|
||||
quiet += idleTick
|
||||
// …but a newline-less PROMPT is not half a line, it is a question,
|
||||
// and this is the only place it can ever be seen. Answer it, show it
|
||||
// in the console (an operator watching a stuck server has a right to
|
||||
// see what the node actually asked), and drop it so it is not glued
|
||||
// onto the front of the next real line.
|
||||
if p := strings.TrimSpace(pending); p != "" {
|
||||
switch {
|
||||
case !loginSent.Load() && s.login != "" && isLoginPrompt(p):
|
||||
s.emitLine(p, false)
|
||||
pending = ""
|
||||
sendLogin()
|
||||
if s.cfg.Password == "" {
|
||||
markConnected()
|
||||
}
|
||||
continue
|
||||
case !pwdSent.Load() && isPasswordPrompt(p):
|
||||
s.emitLine(p, false)
|
||||
pending = ""
|
||||
if s.cfg.Password == "" {
|
||||
// Nothing to answer with. Shown in the console rather than
|
||||
// swallowed: an unanswered "password:" sitting there IS the
|
||||
// explanation for a server that never finishes connecting,
|
||||
// and it is something the operator can act on.
|
||||
continue
|
||||
}
|
||||
sendPassword()
|
||||
markConnected()
|
||||
continue
|
||||
}
|
||||
}
|
||||
quiet += tick
|
||||
// Said once at the first long silence, so a genuinely mute node is
|
||||
// visible without a line every tick.
|
||||
if quiet == quietNotice {
|
||||
if !quietNoticed && quiet >= quietNotice {
|
||||
quietNoticed = true
|
||||
applog.Printf("cluster[%s] no traffic for %s — still connected", s.cfg.Name, quiet)
|
||||
}
|
||||
continue
|
||||
}
|
||||
return connectedAt, fmt.Errorf("read: %w", err)
|
||||
}
|
||||
quietNoticed = false
|
||||
quiet = 0
|
||||
line := pending + chunk
|
||||
pending = ""
|
||||
@@ -508,28 +613,23 @@ func (s *session) runOnce() (time.Time, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Login on explicit prompt.
|
||||
if !loginSent && s.login != "" && isLoginPrompt(line) {
|
||||
_, _ = conn.Write([]byte(s.login + "\r\n"))
|
||||
loginSent = true
|
||||
// Login on explicit prompt — the case where the node DID terminate it with
|
||||
// a newline. The newline-less form is handled on the timeout path above.
|
||||
if !loginSent.Load() && s.login != "" && isLoginPrompt(line) {
|
||||
s.emitLine(line, false)
|
||||
sendLogin()
|
||||
continue
|
||||
}
|
||||
// Password on prompt (rare).
|
||||
if loginSent && s.cfg.Password != "" && isPasswordPrompt(line) {
|
||||
_, _ = conn.Write([]byte(s.cfg.Password + "\r\n"))
|
||||
// Password on prompt.
|
||||
if !pwdSent.Load() && isPasswordPrompt(line) {
|
||||
s.emitLine(line, false)
|
||||
sendPassword()
|
||||
continue
|
||||
}
|
||||
|
||||
// Mark connected once we've sent login OR seen a welcome banner.
|
||||
if s.snapshot().State != StateConnected && (loginSent || isWelcome(line)) {
|
||||
connectedAt = time.Now()
|
||||
s.mu.Lock()
|
||||
s.status.State = StateConnected
|
||||
s.status.ConnectedAt = connectedAt
|
||||
s.status.Error = ""
|
||||
s.mu.Unlock()
|
||||
s.emitStatus()
|
||||
fireInitCommands()
|
||||
if loginSent.Load() || isWelcome(line) {
|
||||
markConnected()
|
||||
}
|
||||
|
||||
// EVERY line goes to the console — spot or not. This is the whole point:
|
||||
@@ -556,6 +656,13 @@ func (s *session) runOnce() (time.Time, error) {
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if s.onSpot != nil {
|
||||
if !firstSpotLogged {
|
||||
firstSpotLogged = true
|
||||
// The gap between the socket opening and the first spot is the
|
||||
// other half of the answer: a long dial is the network, a quick
|
||||
// dial and a long silence is the node (or the login) instead.
|
||||
applog.Printf("cluster[%s] first spot %s after connecting", s.cfg.Name, time.Since(linkUpAt).Round(time.Millisecond))
|
||||
}
|
||||
s.onSpot(spot)
|
||||
}
|
||||
}
|
||||
@@ -683,6 +790,38 @@ func parseSpot(line string) (Spot, bool) {
|
||||
}, true
|
||||
}
|
||||
|
||||
// NewLocalSpot builds the Spot for a DX announcement WE just sent, so it lands
|
||||
// in the operator's own list at once.
|
||||
//
|
||||
// A node does not necessarily broadcast a spot back to the station that sent it:
|
||||
// DXSpider suppresses the echo to the originator, and a node-side filter can eat
|
||||
// it too. So an operator spotted a station, watched their own spot list stay
|
||||
// empty, and concluded the spot had never gone out — when it had. This is the
|
||||
// spot the echo would have carried, built from what we sent, deliberately the
|
||||
// same shape so the UI's call+band de-dupe folds the two into one row on the
|
||||
// nodes that DO echo.
|
||||
func NewLocalSpot(srv ServerConfig, spotter, dxCall string, freqKHz float64, comment string) Spot {
|
||||
freqHz := int64(freqKHz*1000 + 0.5)
|
||||
now := time.Now()
|
||||
sp := Spot{
|
||||
SourceID: srv.ID,
|
||||
SourceName: srv.Name,
|
||||
Spotter: strings.ToUpper(strings.TrimSpace(spotter)),
|
||||
DXCall: strings.ToUpper(strings.TrimSpace(dxCall)),
|
||||
FreqKHz: freqKHz,
|
||||
FreqHz: freqHz,
|
||||
Band: bandFromHz(freqHz),
|
||||
Comment: strings.TrimSpace(comment),
|
||||
TimeUTC: now.UTC().Format("1504") + "Z",
|
||||
ReceivedAt: now,
|
||||
}
|
||||
// Raw reads like the node's own broadcast — it is what anything showing the
|
||||
// source line expects, and it keeps a local spot legible in the log.
|
||||
sp.Raw = fmt.Sprintf("DX de %s: %9.1f %-12s %-30s %s",
|
||||
sp.Spotter, sp.FreqKHz, sp.DXCall, sp.Comment, sp.TimeUTC)
|
||||
return sp
|
||||
}
|
||||
|
||||
func isLoginPrompt(s string) bool {
|
||||
low := strings.ToLower(s)
|
||||
return strings.Contains(low, "login:") ||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A node whose whole greeting is a bare "login:" — no newline, no banner — and
|
||||
// which then demands a password must still log in.
|
||||
//
|
||||
// Reported on f5mzn.org:9000: telnet by hand worked, OpsLog sat at "connecting"
|
||||
// for ever. Two faults met there. The prompts carry no newline, and the read
|
||||
// loop only ever looked at complete LINES, so neither prompt was seen at all;
|
||||
// and the blind 1.5 s login never recorded that it had sent the callsign, which
|
||||
// left the password branch — gated on that flag — permanently switched off.
|
||||
//
|
||||
// The test speaks the node's side literally: "login:" with no newline, then
|
||||
// "password:" with no newline, then a spot. It asserts both answers arrive and
|
||||
// that the session reaches Connected without any welcome banner to lean on.
|
||||
func TestBareLoginAndPasswordPromptsWithoutNewlines(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
host, portStr, _ := net.SplitHostPort(ln.Addr().String())
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
type answers struct{ login, pwd string }
|
||||
got := make(chan answers, 1)
|
||||
go func() {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
rd := bufio.NewReader(c)
|
||||
// No newline, exactly as the node sends it.
|
||||
if _, err := c.Write([]byte("login: ")); err != nil {
|
||||
return
|
||||
}
|
||||
call, err := rd.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if _, err := c.Write([]byte("password: ")); err != nil {
|
||||
return
|
||||
}
|
||||
pwd, err := rd.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
got <- answers{strings.TrimSpace(call), strings.TrimSpace(pwd)}
|
||||
// Something to prove the link is live and parsing again afterwards.
|
||||
_, _ = c.Write([]byte("DX de F4BPO: 14074.0 OY1CT FT8 1234Z\r\n"))
|
||||
time.Sleep(time.Second)
|
||||
}()
|
||||
|
||||
spots := make(chan Spot, 4)
|
||||
s := &session{
|
||||
cfg: ServerConfig{Name: "pwd node", Host: host, Port: port, Password: "s3cret"},
|
||||
login: "F4BPO",
|
||||
onSpot: func(sp Spot) { spots <- sp },
|
||||
onLine: func(Line) {},
|
||||
onStatus: func() {},
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { _, err := s.runOnce(); done <- err }()
|
||||
defer func() { close(s.stopCh); <-done }()
|
||||
|
||||
select {
|
||||
case a := <-got:
|
||||
if a.login != "F4BPO" {
|
||||
t.Errorf("callsign sent = %q, want F4BPO", a.login)
|
||||
}
|
||||
if a.pwd != "s3cret" {
|
||||
t.Errorf("password sent = %q, want s3cret — the prompt carried no newline", a.pwd)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("the node's newline-less prompts were never answered")
|
||||
}
|
||||
|
||||
select {
|
||||
case sp := <-spots:
|
||||
if sp.DXCall != "OY1CT" {
|
||||
t.Errorf("spot from the wrong station: %+v", sp)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("no spot after the login — the stream is not being parsed")
|
||||
}
|
||||
|
||||
// Connected without a welcome banner: the handshake alone must be enough.
|
||||
if st := s.snapshot().State; st != StateConnected {
|
||||
t.Errorf("state = %q, want %q — the server would still show as connecting", st, StateConnected)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A TEXT column that an index is built on must be listed in varcharColumns.
|
||||
//
|
||||
// MySQL cannot index a TEXT column without a prefix length: the migration dies
|
||||
// with error 1170 — and it dies again on every startup afterwards, leaving the
|
||||
// operator with a logbook that will not connect and no way forward from the
|
||||
// interface. The translator only emits VARCHAR for the names in varcharColumns,
|
||||
// and that list is maintained by hand.
|
||||
//
|
||||
// So: read the migrations, work out which columns are TEXT, find which are
|
||||
// indexed, and insist the list covers the overlap. Integer columns are indexed
|
||||
// perfectly well and are none of this test's business.
|
||||
func TestIndexedTextColumnsAreVarchar(t *testing.T) {
|
||||
files, err := migrationsFS.ReadDir("migrations")
|
||||
if err != nil {
|
||||
t.Fatalf("read migrations: %v", err)
|
||||
}
|
||||
|
||||
// name → declared type, from "ADD COLUMN name TYPE" and from the column
|
||||
// lines inside a CREATE TABLE body.
|
||||
declared := map[string]string{}
|
||||
reAdd := regexp.MustCompile(`(?i)ADD\s+COLUMN\s+` + "`" + `?(\w+)` + "`" + `?\s+(\w+)`)
|
||||
reCol := regexp.MustCompile(`(?im)^\s*` + "`" + `?(\w+)` + "`" + `?\s+(TEXT|VARCHAR|INTEGER|INT|REAL|BLOB|DATETIME|BOOLEAN)\b`)
|
||||
reIdx := regexp.MustCompile(`(?i)CREATE\s+INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?\S+\s+ON\s+\S+\s*\(([^)]*)\)`)
|
||||
|
||||
var indexed []struct{ file, col string }
|
||||
for _, f := range files {
|
||||
if f.IsDir() || !strings.HasSuffix(f.Name(), ".sql") {
|
||||
continue
|
||||
}
|
||||
body, err := migrationsFS.ReadFile("migrations/" + f.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", f.Name(), err)
|
||||
}
|
||||
src := string(body)
|
||||
for _, m := range reAdd.FindAllStringSubmatch(src, -1) {
|
||||
declared[strings.ToLower(m[1])] = strings.ToUpper(m[2])
|
||||
}
|
||||
for _, m := range reCol.FindAllStringSubmatch(src, -1) {
|
||||
if _, seen := declared[strings.ToLower(m[1])]; !seen {
|
||||
declared[strings.ToLower(m[1])] = strings.ToUpper(m[2])
|
||||
}
|
||||
}
|
||||
for _, m := range reIdx.FindAllStringSubmatch(src, -1) {
|
||||
for _, col := range strings.Split(m[1], ",") {
|
||||
col = strings.TrimSpace(col)
|
||||
if col == "" || strings.Contains(col, "(") {
|
||||
continue // a prefix length or an expression is MySQL-safe already
|
||||
}
|
||||
col = strings.Trim(col, "`\"")
|
||||
if i := strings.IndexAny(col, " \t"); i > 0 {
|
||||
col = col[:i] // "col DESC"
|
||||
}
|
||||
indexed = append(indexed, struct{ file, col string }{f.Name(), strings.ToLower(col)})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(indexed) == 0 || len(declared) == 0 {
|
||||
t.Fatal("nothing parsed out of the migrations — this test has stopped checking anything")
|
||||
}
|
||||
|
||||
textIndexed := 0
|
||||
for _, ix := range indexed {
|
||||
typ, known := declared[ix.col]
|
||||
if !known || typ != "TEXT" {
|
||||
continue // an integer index, or a column this test could not type
|
||||
}
|
||||
textIndexed++
|
||||
if !varcharColumns[ix.col] {
|
||||
t.Errorf("%s indexes the TEXT column %q, which is not in varcharColumns — "+
|
||||
"on MySQL that migration fails with error 1170 and the logbook stops connecting for good",
|
||||
ix.file, ix.col)
|
||||
}
|
||||
}
|
||||
if textIndexed == 0 {
|
||||
t.Fatal("no indexed TEXT column found — the parsing has drifted and this test checks nothing")
|
||||
}
|
||||
t.Logf("%d indexed TEXT column(s) checked against varcharColumns", textIndexed)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
-- A stable identity per contact, for folder-based synchronisation.
|
||||
--
|
||||
-- Two PCs exchanging changes through a shared folder need to name the SAME
|
||||
-- contact in both logs. "the QSO with M0ABC at 14:32" is a guess, and the two
|
||||
-- machines can disagree about which row that is — so an edit or a deletion
|
||||
-- cannot be addressed at all without an identity that travels with the record.
|
||||
--
|
||||
-- A REAL COLUMN, indexed, rather than a key inside extras_json. The identity is
|
||||
-- looked up once per incoming change, and on a 120 000-QSO logbook scanning
|
||||
-- JSON for it would turn every sync into a full table read. A column costs one
|
||||
-- migration; the JSON would cost a scan every time.
|
||||
--
|
||||
-- sync_uid is listed in varcharColumns (internal/db/mysql.go): MySQL cannot
|
||||
-- index a TEXT column without a prefix length, and a migration that tries dies
|
||||
-- with error 1170 on every startup thereafter, with no way out from the UI.
|
||||
--
|
||||
-- Empty on every existing row, and it STAYS empty on most of them. The change
|
||||
-- log starts empty too, so contacts logged before synchronisation was switched
|
||||
-- on are never exchanged: nothing has to be copied across, and an identity is
|
||||
-- stamped only on a contact that is actually logged, edited or deleted from
|
||||
-- then on. Seeding a second machine with the existing log is a one-time copy of
|
||||
-- the database or an ADIF import, not something synchronisation does.
|
||||
ALTER TABLE qso ADD COLUMN sync_uid TEXT NOT NULL DEFAULT '';
|
||||
CREATE INDEX IF NOT EXISTS idx_qso_sync_uid ON qso (sync_uid);
|
||||
@@ -0,0 +1,8 @@
|
||||
-- The lookup cache gains the IOTA reference QRZ was already sending.
|
||||
--
|
||||
-- QRZ's XML carries <iota>EU-048</iota> for an operator on an island, and
|
||||
-- nothing read it — so the one place an IOTA reference can be known BEFORE the
|
||||
-- contact is logged was thrown away on every lookup. There is no live "who is
|
||||
-- on an island right now" feed anywhere (unlike POTA), which makes the callbook
|
||||
-- record the practical source.
|
||||
ALTER TABLE callsign_cache ADD COLUMN iota TEXT;
|
||||
@@ -67,6 +67,10 @@ var varcharColumns = map[string]bool{
|
||||
"callsign": true, "qso_date": true, "band": true, "mode": true,
|
||||
"grid": true, "station_callsign": true, "state": true, "contest_id": true,
|
||||
"sat_name": true, "prop_mode": true, "sig": true, "wwff_ref": true, "skcc": true,
|
||||
"sync_uid": true, // qso index (0029) — folder-sync identity, looked up per record
|
||||
// A new indexed TEXT column MUST be added here. TestIndexedTextColumnsAreVarchar
|
||||
// reads the migrations and fails if one is missing, because the alternative is
|
||||
// a MySQL logbook that stops connecting for good on error 1170.
|
||||
// integrations_udp index (0011)
|
||||
"direction": true,
|
||||
// award_references composite primary key (0017)
|
||||
|
||||
@@ -117,6 +117,13 @@ func UploadClublogADIF(ctx context.Context, client *http.Client, cfg ServiceConf
|
||||
if api == "" {
|
||||
api = clublogAppAPIKey
|
||||
}
|
||||
// putlogs.php reads the upload as an ADIF *file*, so it needs a header.
|
||||
// Callers that already build a full document (the QSL Manager) pass one;
|
||||
// callers that only have <EOR>-terminated records (the on-close flush) do
|
||||
// not, and a headerless file is rejected. Same rule as the LoTW writer.
|
||||
if !strings.Contains(strings.ToUpper(adifDoc), "<EOH>") {
|
||||
adifDoc = "OpsLog Club Log upload\n<PROGRAMID:6>OpsLog <EOH>\n" + adifDoc
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
|
||||
@@ -267,6 +267,78 @@ func UploadEQSL(ctx context.Context, client *http.Client, user, pswd, qthNick, a
|
||||
return UploadResult{OK: false, Message: reason}, fmt.Errorf("eqsl: upload failed: %s", reason)
|
||||
}
|
||||
|
||||
// eqslBatchMax is the largest number of records eQSL asks a single upload to
|
||||
// carry ("upload only files smaller than about 1000 records at a time", eQSL's
|
||||
// own ImportADIF interface notes). Callers chunk to this.
|
||||
const eqslBatchMax = 1000
|
||||
|
||||
// UploadEQSLBatch pushes MANY ADIF records to eQSL.cc in ONE request.
|
||||
//
|
||||
// ImportADIF.cfm is a file importer, not a per-QSO endpoint: it takes one *or
|
||||
// more* QSOs and answers "Result: X out of Y records added" — the plural in its
|
||||
// own reply. So an on-close sweep or a bulk upload is one request, not one per
|
||||
// contact. No ADIF header is prepended: the single-record path has always posted
|
||||
// bare <EOR> records and eQSL accepts them (per ADIF, a file starting with '<'
|
||||
// has no header), and there is no reason to change what is known to work.
|
||||
//
|
||||
// A PARTIAL result ("97 out of 100") sets Ignored so the caller can say so.
|
||||
// eQSL does not identify which records it left out, and in practice they are
|
||||
// QSOs it already holds — the same duplicate that UploadEQSL reports as success.
|
||||
func UploadEQSLBatch(ctx context.Context, client *http.Client, user, pswd, qthNick string, records []string) (UploadResult, error) {
|
||||
user = strings.ToUpper(strings.TrimSpace(user))
|
||||
if user == "" {
|
||||
return UploadResult{}, fmt.Errorf("eqsl: username (callsign) not set")
|
||||
}
|
||||
if strings.TrimSpace(pswd) == "" {
|
||||
return UploadResult{}, fmt.Errorf("eqsl: password not set")
|
||||
}
|
||||
docs := make([]string, 0, len(records))
|
||||
for _, r := range records {
|
||||
if strings.TrimSpace(r) == "" {
|
||||
continue
|
||||
}
|
||||
docs = append(docs, eqslRecordWithNickname(strings.TrimRight(r, "\r\n"), qthNick))
|
||||
}
|
||||
if len(docs) == 0 {
|
||||
return UploadResult{}, fmt.Errorf("eqsl: empty adif batch")
|
||||
}
|
||||
if len(docs) > eqslBatchMax {
|
||||
return UploadResult{}, fmt.Errorf("eqsl: batch of %d exceeds the %d-record limit", len(docs), eqslBatchMax)
|
||||
}
|
||||
|
||||
body, err := eqslPost(ctx, client, user, pswd, strings.Join(docs, "\n"))
|
||||
if err != nil {
|
||||
return UploadResult{OK: false, Message: body}, err
|
||||
}
|
||||
if reason := authErrEQSL(body); reason != "" {
|
||||
return UploadResult{OK: false, Message: reason}, fmt.Errorf("eqsl: %s", reason)
|
||||
}
|
||||
|
||||
// The counted result is read FIRST here, unlike the single-record path: a
|
||||
// batch reply routinely carries both "Result: 97 out of 100 records added"
|
||||
// and a "Bad record: Duplicate" line for the other three, and matching the
|
||||
// duplicate first would throw away the count that says the rest went in.
|
||||
if m := eqslResultRe.FindStringSubmatch(body); m != nil {
|
||||
added, _ := strconv.Atoi(m[1])
|
||||
total, _ := strconv.Atoi(m[2])
|
||||
if added >= 1 {
|
||||
return UploadResult{OK: true, Message: strings.TrimSpace(m[0]), Ignored: added < total}, nil
|
||||
}
|
||||
// "0 out of N" — nothing added. A re-upload of QSOs eQSL already holds
|
||||
// lands here, and that is not a failure.
|
||||
if strings.Contains(strings.ToLower(body), "duplicate") {
|
||||
return UploadResult{OK: true, Message: "already in logbook"}, nil
|
||||
}
|
||||
reason := eqslReason(body)
|
||||
return UploadResult{OK: false, Message: reason}, fmt.Errorf("eqsl: batch upload failed: %s", reason)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(body), "duplicate") {
|
||||
return UploadResult{OK: true, Message: "already in logbook"}, nil
|
||||
}
|
||||
reason := eqslReason(body)
|
||||
return UploadResult{OK: false, Message: reason}, fmt.Errorf("eqsl: batch upload failed: %s", reason)
|
||||
}
|
||||
|
||||
// eqslReason trims an eQSL reply to a short human-readable reason: the first
|
||||
// "Error:" / "Warning:" / "Bad record:" line if present, else the whole body
|
||||
// (capped), else a generic phrase.
|
||||
|
||||
+180
-30
@@ -295,9 +295,10 @@ func (m *Manager) CloseUploadCount() int {
|
||||
}
|
||||
|
||||
// FlushOnClose uploads every QSO due for an on-close push, scanning the whole
|
||||
// logbook (not just this session). Called from the shutdown sequence. QRZ/Club
|
||||
// Log go one-by-one (fast HTTP); LoTW is signed and uploaded as a single TQSL
|
||||
// batch. Returns the number of QSOs uploaded successfully.
|
||||
// logbook (not just this session). Called from the shutdown sequence. QRZ and
|
||||
// the rest go one-by-one (fast HTTP, no batch API); LoTW is signed and uploaded
|
||||
// as a single TQSL batch, and Club Log goes through its batch endpoint.
|
||||
// Returns the number of QSOs uploaded successfully.
|
||||
func (m *Manager) FlushOnClose() int {
|
||||
if m.deps.CloseUploadIDs == nil {
|
||||
return 0
|
||||
@@ -312,41 +313,190 @@ func (m *Manager) FlushOnClose() int {
|
||||
switch svc {
|
||||
case ServiceLoTW:
|
||||
uploaded += m.flushLoTWBatch(ids, cfg.LoTW)
|
||||
case ServiceQRZ:
|
||||
for _, id := range ids {
|
||||
if ok, _ := m.upload(svc, id, cfg.QRZ); ok {
|
||||
uploaded++
|
||||
}
|
||||
}
|
||||
case ServiceClublog:
|
||||
for _, id := range ids {
|
||||
if ok, _ := m.upload(svc, id, cfg.Clublog); ok {
|
||||
uploaded++
|
||||
}
|
||||
}
|
||||
case ServiceHRDLog:
|
||||
for _, id := range ids {
|
||||
if ok, _ := m.upload(svc, id, cfg.HRDLog); ok {
|
||||
uploaded++
|
||||
}
|
||||
}
|
||||
uploaded += m.flushClublogBatch(ids, cfg.Clublog)
|
||||
case ServiceEQSL:
|
||||
for _, id := range ids {
|
||||
if ok, _ := m.upload(svc, id, cfg.EQSL); ok {
|
||||
uploaded++
|
||||
}
|
||||
}
|
||||
uploaded += m.flushEQSLBatch(ids, cfg.EQSL)
|
||||
case ServiceQRZ:
|
||||
uploaded += m.flushOneByOne(svc, ids, cfg.QRZ)
|
||||
case ServiceHRDLog:
|
||||
uploaded += m.flushOneByOne(svc, ids, cfg.HRDLog)
|
||||
case ServiceCloudlog:
|
||||
for _, id := range ids {
|
||||
if ok, _ := m.upload(svc, id, cfg.Cloudlog); ok {
|
||||
uploaded++
|
||||
}
|
||||
}
|
||||
uploaded += m.flushOneByOne(svc, ids, cfg.Cloudlog)
|
||||
}
|
||||
}
|
||||
return uploaded
|
||||
}
|
||||
|
||||
// uploadPace is the shortest gap between two consecutive single-QSO uploads in
|
||||
// an on-close sweep. QRZ, HRDLog and Cloudlog have no batch endpoint — HRDLog's
|
||||
// NewEntry.aspx keeps only the first record of a multi-record ADIF — so a sweep
|
||||
// of a freshly imported log is unavoidably one request per contact. It does not
|
||||
// have to arrive as fast as the link allows, though: that burst is what a
|
||||
// service reads as a robot, and what got an operator's IP threatened at Club Log
|
||||
// (see flushClublogBatch). The gap costs nothing in practice, since a round trip
|
||||
// to any of these already takes longer than it.
|
||||
const uploadPace = 200 * time.Millisecond
|
||||
|
||||
// flushOneByOne uploads ids one request at a time, paced. For the services that
|
||||
// have no batch API; everything else has its own flush<Service>Batch.
|
||||
func (m *Manager) flushOneByOne(svc Service, ids []int64, cfg ServiceConfig) int {
|
||||
uploaded := 0
|
||||
for i, id := range ids {
|
||||
if i > 0 {
|
||||
time.Sleep(uploadPace)
|
||||
}
|
||||
if ok, _ := m.upload(svc, id, cfg); ok {
|
||||
uploaded++
|
||||
}
|
||||
}
|
||||
return uploaded
|
||||
}
|
||||
|
||||
// eqslBatchChunk is how many QSOs go into one ImportADIF.cfm request. eQSL's own
|
||||
// limit is ten times this (eqslBatchMax); the smaller chunk keeps one refused
|
||||
// record from taking a thousand others down with it, and keeps the form body
|
||||
// small enough to be unremarkable.
|
||||
const eqslBatchChunk = 100
|
||||
|
||||
// flushEQSLBatch uploads the on-close eQSL QSOs through ImportADIF.cfm in
|
||||
// batches instead of one request per contact. Same reasoning as
|
||||
// flushClublogBatch — eQSL's import endpoint has always taken a whole file, so
|
||||
// the one-at-a-time loop was making hundreds of requests it never needed to.
|
||||
func (m *Manager) flushEQSLBatch(ids []int64, cfg ServiceConfig) int {
|
||||
uploaded := 0
|
||||
var records []string
|
||||
var kept []int64
|
||||
|
||||
send := func() {
|
||||
if len(records) == 0 {
|
||||
return
|
||||
}
|
||||
// nil client: UploadEQSLBatch then builds one with a 30 s timeout rather
|
||||
// than reusing the 20 s budget of a single realtime QSO.
|
||||
res, err := UploadEQSLBatch(context.Background(), nil, cfg.Username, cfg.Password, cfg.QTHNickname, records)
|
||||
if err != nil || !res.OK {
|
||||
if err == nil {
|
||||
err = errFromResult(res)
|
||||
}
|
||||
m.logf("extsvc: eqsl batch upload (%d QSOs) failed: %v", len(kept), err)
|
||||
if m.deps.NotifyError != nil {
|
||||
m.deps.NotifyError(ServiceEQSL, 0, err)
|
||||
}
|
||||
} else {
|
||||
// res.Ignored means eQSL took the file but left records out. Say the
|
||||
// count out loud: the whole chunk is still marked sent (eQSL never
|
||||
// says WHICH it dropped, and in practice they are QSOs it already
|
||||
// had), so the log line is the only trace of the shortfall.
|
||||
if res.Ignored {
|
||||
m.logf("extsvc: eqsl batch upload PARTIAL (%d QSOs sent) %s", len(kept), res.Message)
|
||||
} else {
|
||||
m.logf("extsvc: eqsl batch upload OK (%d QSOs) %s", len(kept), res.Message)
|
||||
}
|
||||
if m.deps.MarkUploaded != nil {
|
||||
for _, id := range kept {
|
||||
m.deps.MarkUploaded(ServiceEQSL, id, res.LogID)
|
||||
}
|
||||
}
|
||||
uploaded += len(kept)
|
||||
}
|
||||
records = records[:0]
|
||||
kept = kept[:0]
|
||||
}
|
||||
|
||||
for _, id := range ids {
|
||||
if m.deps.ShouldUpload != nil && !m.deps.ShouldUpload(ServiceEQSL, id) {
|
||||
continue
|
||||
}
|
||||
// eQSL keeps the QSO's own station call; the account is identified by the
|
||||
// credentials and the optional QTH nickname — as in upload().
|
||||
rec, ok := m.deps.BuildADIF(id, "")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
records = append(records, rec)
|
||||
kept = append(kept, id)
|
||||
if len(records) >= eqslBatchChunk {
|
||||
send()
|
||||
}
|
||||
}
|
||||
send()
|
||||
return uploaded
|
||||
}
|
||||
|
||||
// clublogBatchChunk is how many QSOs go into one putlogs.php request. Club Log
|
||||
// dedupes server-side, so chunking is not about correctness — it keeps a single
|
||||
// malformed record from failing a whole ten-thousand-QSO document, and matches
|
||||
// what the QSL Manager's bulk upload already uses.
|
||||
const clublogBatchChunk = 100
|
||||
|
||||
// flushClublogBatch uploads the on-close Club Log QSOs through the BATCH
|
||||
// endpoint (putlogs.php) rather than one realtime.php call each.
|
||||
//
|
||||
// It used to walk the ids and call UploadClublog per QSO. On-close upload sweeps
|
||||
// the WHOLE logbook, so importing an ADIF — or simply switching Club Log on over
|
||||
// an existing log — turned one app close into hundreds of realtime.php posts.
|
||||
// That endpoint is reserved for an operator logging contacts as they work them,
|
||||
// and Club Log blocks the IP of anything that batches through it: an OpsLog user
|
||||
// was flagged by G7VJR for 185 QSOs in four minutes, which is this loop, not a
|
||||
// pile-up. Batch upload is the mechanism Club Log provides for exactly this.
|
||||
func (m *Manager) flushClublogBatch(ids []int64, cfg ServiceConfig) int {
|
||||
uploaded := 0
|
||||
var records []string
|
||||
var kept []int64
|
||||
|
||||
send := func() {
|
||||
if len(records) == 0 {
|
||||
return
|
||||
}
|
||||
// nil client on purpose: UploadClublogADIF then builds one with a 120 s
|
||||
// timeout. m.deps.Client is the 20 s budget of a single realtime QSO,
|
||||
// which a hundred-QSO document on a slow link would blow through.
|
||||
res, err := UploadClublogADIF(context.Background(), nil, cfg, strings.Join(records, "\n"))
|
||||
if err != nil || !res.OK {
|
||||
if err == nil {
|
||||
err = errFromResult(res)
|
||||
}
|
||||
m.logf("extsvc: clublog batch upload (%d QSOs) failed: %v", len(kept), err)
|
||||
if m.deps.NotifyError != nil {
|
||||
m.deps.NotifyError(ServiceClublog, 0, err)
|
||||
}
|
||||
} else {
|
||||
m.logf("extsvc: clublog batch upload OK (%d QSOs) %s", len(kept), res.Message)
|
||||
if m.deps.MarkUploaded != nil {
|
||||
for _, id := range kept {
|
||||
m.deps.MarkUploaded(ServiceClublog, id, res.LogID)
|
||||
}
|
||||
}
|
||||
uploaded += len(kept)
|
||||
}
|
||||
records = records[:0]
|
||||
kept = kept[:0]
|
||||
}
|
||||
|
||||
for _, id := range ids {
|
||||
// Skip QSOs not eligible (already sent). The wrong-logbook guard that
|
||||
// upload() applies per QSO is not repeated here: closeUploadIDs has
|
||||
// already filtered the sweep down to this logbook's callsign.
|
||||
if m.deps.ShouldUpload != nil && !m.deps.ShouldUpload(ServiceClublog, id) {
|
||||
continue
|
||||
}
|
||||
// Club Log takes the logbook callsign as its own form field, so the ADIF
|
||||
// keeps the QSO's own station call (no override) — as in upload().
|
||||
rec, ok := m.deps.BuildADIF(id, "")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
records = append(records, rec)
|
||||
kept = append(kept, id)
|
||||
if len(records) >= clublogBatchChunk {
|
||||
send()
|
||||
}
|
||||
}
|
||||
send()
|
||||
return uploaded
|
||||
}
|
||||
|
||||
// flushLoTWBatch signs+uploads all queued LoTW QSOs in one TQSL run, then
|
||||
// stamps each as uploaded on success.
|
||||
func (m *Manager) flushLoTWBatch(ids []int64, cfg ServiceConfig) int {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package udp
|
||||
|
||||
import "testing"
|
||||
|
||||
// A remote-call <FREQ> arrives in whatever unit the sender happens to use, and
|
||||
// the same sender uses more than one. Every form has to land on the same dial
|
||||
// frequency, because the alternative is a rig sent to the wrong band.
|
||||
func TestRemoteTuneUnits(t *testing.T) {
|
||||
const m20 = 14_074_000
|
||||
for _, c := range []struct {
|
||||
in string
|
||||
want int64
|
||||
why string
|
||||
}{
|
||||
{"14.074", m20, "MHz, the documented DXHunter form"},
|
||||
{"10.136", 10_136_000, "MHz, 30 m"},
|
||||
{"14074", m20, "kHz"},
|
||||
{"14074.0", m20, "kHz with a decimal point"},
|
||||
{"1407400", m20, "tens of Hz — what N1MM RadioInfo publishes, echoed back"},
|
||||
{"2107400", 21_074_000, "the value from the field log that failed every time"},
|
||||
{"14074000", m20, "Hz"},
|
||||
{"7000000", 7_000_000, "Hz on 40 m, not tens of Hz on 4 m"},
|
||||
{"0", 0, "no frequency"},
|
||||
{"999999999999", 0, "nothing plausible — tune nothing rather than guess"},
|
||||
{"abc", 0, "not a number"},
|
||||
} {
|
||||
if got := remoteTuneHz(c.in); got != c.want {
|
||||
t.Errorf("remoteTuneHz(%q) = %d, want %d (%s)", c.in, got, c.want, c.why)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package udp
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// feed runs one datagram through a remote-call listener and returns the event
|
||||
// it produced, or nil.
|
||||
func feed(t *testing.T, pkt []byte) *Event {
|
||||
t.Helper()
|
||||
out := make(chan Event, 4)
|
||||
s := &Server{
|
||||
cfg: Config{ID: 1, Name: "DX HUNTER", ServiceType: ServiceRemoteCall, Port: 2241},
|
||||
out: out,
|
||||
}
|
||||
s.handle(pkt, &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 2241})
|
||||
select {
|
||||
case ev := <-out:
|
||||
return &ev
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// A RadioInfo datagram must never be read as a remote-call request.
|
||||
//
|
||||
// This is the loop from a reported session. An inbound remote-call row and an
|
||||
// outbound RadioInfo row shared port 2241, so every datagram OpsLog sent came
|
||||
// straight back on the loopback. The tag-stripping heuristic read its last
|
||||
// token — <ActiveRadioNr>1</ActiveRadioNr> — as the callsign "1", and <Freq> as
|
||||
// a tune request for the frequency the rig was already on.
|
||||
//
|
||||
// It stayed harmless only while <Freq> was misread as MHz: the tune failed "out
|
||||
// of the CAT range" and the loop died there. Reading the unit correctly closed
|
||||
// it, and with JTDX "Fake It" — which shifts the dial for every over — each
|
||||
// transmission set off a burst of sets echoing between OpsLog and itself until
|
||||
// the rig stopped answering IF; and the shared CAT link dropped.
|
||||
func TestRadioInfoIsNotARemoteCall(t *testing.T) {
|
||||
pkt := BuildN1MMRadioInfo("F5PHW", 24_915_000, 24_915_000, "FT8", "F5PHW")
|
||||
|
||||
// The trap this closes: the payload really does parse as a plausible dial
|
||||
// frequency, so nothing downstream would have questioned it.
|
||||
m := remoteFreqRe.FindStringSubmatch(string(pkt))
|
||||
if m == nil {
|
||||
t.Fatal("the RadioInfo no longer carries a <Freq> — this test is checking nothing")
|
||||
}
|
||||
if hz := remoteTuneHz(m[1]); hz != 24_915_000 {
|
||||
t.Fatalf("remoteTuneHz(%q) = %d — the dial frequency back is what made the loop live", m[1], hz)
|
||||
}
|
||||
|
||||
if ev := feed(t, pkt); ev != nil {
|
||||
t.Errorf("a RadioInfo produced a remote-call event %+v — the rig would be re-tuned to where it already is", *ev)
|
||||
}
|
||||
}
|
||||
|
||||
// A callsign has a letter in it. Refusing by shape as well as by name means the
|
||||
// next program to broadcast its state on this port cannot drive the rig either.
|
||||
func TestRemoteCallNeedsALetter(t *testing.T) {
|
||||
for _, body := range []string{"<CALLSIGN>1</CALLSIGN>", "<CALLSIGN>0</CALLSIGN>", "12345"} {
|
||||
if ev := feed(t, []byte(body)); ev != nil {
|
||||
t.Errorf("%q was accepted as a callsign: %+v", body, *ev)
|
||||
}
|
||||
}
|
||||
// A genuine spot click still gets through, tune request and all.
|
||||
ev := feed(t, []byte("<CALLSIGN>OJ0YL<FREQ>10.112<MODE>CW"))
|
||||
if ev == nil {
|
||||
t.Fatal("a genuine spot click produced no event")
|
||||
}
|
||||
if ev.DXCall != "OJ0YL" || ev.TuneFreqHz != 10_112_000 || ev.TuneMode != "CW" {
|
||||
t.Errorf("spot click decoded as %+v, want OJ0YL / 10112000 Hz / CW", *ev)
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,38 @@ import (
|
||||
"hamlog/internal/applog"
|
||||
)
|
||||
|
||||
// remoteTuneHz turns a <FREQ> value from a remote-call packet into Hz.
|
||||
//
|
||||
// The field has NO agreed unit, and the same sender uses two of them. DXHunter
|
||||
// documents "<FREQ>10.136" — MHz — but a log from a working station showed it
|
||||
// echoing "<FREQ>2107400" straight back: the frequency OpsLog had just
|
||||
// published to it in the N1MM RadioInfo broadcast, whose <Freq> is in units of
|
||||
// 10 Hz. Read as MHz, that asked the rig for 2 107 400 MHz, and every tune
|
||||
// request failed with "out of the 11-digit CAT range" from the first second
|
||||
// after launch.
|
||||
//
|
||||
// So the unit is inferred: try each one and keep the first that lands on an
|
||||
// amateur band. Anything a station is asked to tune to is, by definition, in
|
||||
// one. Hz is tried before 10 Hz because the one overlap between them — a 40 m
|
||||
// frequency in Hz reads as a 4 m one in tens of Hz — is far more likely to be
|
||||
// 40 m. Nothing plausible means nothing is tuned: a wrong band is worse than a
|
||||
// request that visibly did nothing.
|
||||
func remoteTuneHz(s string) int64 {
|
||||
v, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil || v <= 0 {
|
||||
return 0
|
||||
}
|
||||
for _, hz := range []int64{int64(v * 1e6), int64(v * 1e3), int64(v), int64(v * 10)} {
|
||||
if bandFromHz(hz) != "" {
|
||||
return hz
|
||||
}
|
||||
}
|
||||
// Refusing in silence is how the previous version's failure looked from the
|
||||
// outside: a spot clicked in another program, and nothing happening here.
|
||||
applog.Printf("udp: remote_call <FREQ>%s is not a frequency in any amateur band in MHz, kHz or Hz — not tuning\n", s)
|
||||
return 0
|
||||
}
|
||||
|
||||
// remoteFreqRe / remoteModeRe pull the optional tune request out of a
|
||||
// ServiceRemoteCall packet: "<FREQ>10.136" (MHz) and "<MODE>FT8". Both accept
|
||||
// an optional closing tag for proper-XML senders.
|
||||
@@ -391,15 +423,32 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
||||
// Strip every angle-bracket tag, normalise whitespace, take the
|
||||
// last non-empty token. Upper-case for downstream consistency.
|
||||
text := string(pkt)
|
||||
// NEVER act on an N1MM RadioInfo datagram.
|
||||
//
|
||||
// It is not a spot click, it is a radio TELLING the world where it is —
|
||||
// and on a station where an inbound remote-call row and an outbound
|
||||
// RadioInfo row share a port, the one OpsLog just sent arrives straight
|
||||
// back on the loopback. The tag-stripping below then reads its last token,
|
||||
// <ActiveRadioNr>1</ActiveRadioNr>, as the callsign "1", and <Freq> as a
|
||||
// tune request — for the frequency the rig is already on.
|
||||
//
|
||||
// That was harmless only for as long as <Freq> was misread: in tens of Hz
|
||||
// it looks like a wild number, every tune failed "out of the CAT range",
|
||||
// and the loop died there. Reading the unit correctly closed it. With
|
||||
// WSJT-X/JTDX "Fake It", which shifts the dial for each over, every
|
||||
// transmission then produced a burst of sets echoing between OpsLog and
|
||||
// itself until the rig stopped answering IF; and the shared CAT link
|
||||
// dropped. Reported as Fake It causing CAT disconnections.
|
||||
if low := strings.ToLower(text); strings.Contains(low, "<radioinfo") {
|
||||
return
|
||||
}
|
||||
// Optional tune request: <FREQ>MHz and <MODE>str ride along with the
|
||||
// callsign so a DXHunter spot click can drive OpsLog's CAT. Extract
|
||||
// (and cut) them BEFORE the generic tag-stripping below, which would
|
||||
// otherwise leave their values as stray tokens and corrupt the
|
||||
// "last token = callsign" heuristic.
|
||||
if m := remoteFreqRe.FindStringSubmatch(text); m != nil {
|
||||
if mhz, err := strconv.ParseFloat(m[1], 64); err == nil && mhz > 0 {
|
||||
ev.TuneFreqHz = int64(mhz * 1e6)
|
||||
}
|
||||
ev.TuneFreqHz = remoteTuneHz(m[1])
|
||||
text = strings.Replace(text, m[0], " ", 1)
|
||||
}
|
||||
if m := remoteModeRe.FindStringSubmatch(text); m != nil {
|
||||
@@ -424,7 +473,16 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
||||
if len(parts) == 0 {
|
||||
return
|
||||
}
|
||||
ev.DXCall = strings.ToUpper(parts[len(parts)-1])
|
||||
call := strings.ToUpper(parts[len(parts)-1])
|
||||
// A callsign has a letter in it. Without this, any status XML that ends
|
||||
// in a number is read as a station — the RadioInfo above was exactly
|
||||
// that, and refusing it by shape as well as by name means the next
|
||||
// program to broadcast its state on this port cannot drive the rig
|
||||
// either.
|
||||
if !strings.ContainsAny(call, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") {
|
||||
return
|
||||
}
|
||||
ev.DXCall = call
|
||||
case ServiceN1MM:
|
||||
adifText, ok, err := ParseN1MM(pkt)
|
||||
if err != nil {
|
||||
@@ -568,10 +626,36 @@ func (m *Manager) Reload(ctx context.Context) []string {
|
||||
m.inbound[c.ID] = srv
|
||||
m.mu.Unlock()
|
||||
}
|
||||
warnSharedPorts(cfgs)
|
||||
applog.Printf("udp: Reload done — %d server(s) running, %d error(s)", len(m.inbound), len(errs))
|
||||
return errs
|
||||
}
|
||||
|
||||
// warnSharedPorts names an inbound and an outbound row sitting on the same
|
||||
// port, because that is a loop: what OpsLog sends there, OpsLog receives.
|
||||
//
|
||||
// It is how a station ended up re-tuning its own rig from its own RadioInfo
|
||||
// broadcasts. The parser refuses that particular payload now, but the
|
||||
// arrangement stays wrong for anything else that lands on the port, and it is
|
||||
// invisible in a settings panel that shows one row at a time.
|
||||
func warnSharedPorts(cfgs []Config) {
|
||||
in := map[int]string{}
|
||||
for _, c := range cfgs {
|
||||
if c.Enabled && c.Direction != Outbound {
|
||||
in[c.Port] = c.Name
|
||||
}
|
||||
}
|
||||
for _, c := range cfgs {
|
||||
if !c.Enabled || c.Direction != Outbound {
|
||||
continue
|
||||
}
|
||||
if name, ok := in[c.Port]; ok {
|
||||
applog.Printf("udp: %q sends on port %d and %q listens on it — OpsLog will receive its own messages there; give one of the two another port",
|
||||
c.Name, c.Port, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Outbound returns the active outbound configs matching a service type.
|
||||
// Used by the QSO save path to push notifications to listeners.
|
||||
func (m *Manager) Outbound(service ServiceType) []Config {
|
||||
|
||||
@@ -46,6 +46,51 @@ street, no image.
|
||||
</Callsign>
|
||||
```
|
||||
|
||||
```
|
||||
<QRZDatabase xmlns="http://xmldata.qrz.com" version="1.36">
|
||||
<div id="in-page-channel-node-id" data-channel-name="in_page_channel_ogW01U"/>
|
||||
<Callsign>
|
||||
<call>F5IRH</call>
|
||||
<dxcc>227</dxcc>
|
||||
<fname>AVRILLON</fname>
|
||||
<name>Max</name>
|
||||
<addr1>La Grand Prairie</addr1>
|
||||
<addr2>Le Palais BELLE-ILE-EN-MER</addr2>
|
||||
<zip>56360</zip>
|
||||
<country>France</country>
|
||||
<lat>47.339686</lat>
|
||||
<lon>-3.156500</lon>
|
||||
<grid>IN87ki</grid>
|
||||
<ccode>97</ccode>
|
||||
<land>France</land>
|
||||
<codes>TP</codes>
|
||||
<qslmgr>VIA BURO</qslmgr>
|
||||
<email>f5irh@free.fr</email>
|
||||
<u_views>8005</u_views>
|
||||
<bio>2463</bio>
|
||||
<biodate>2015-07-16 00:29:49</biodate>
|
||||
<image>https://cdn-xml.qrz.com/h/f5irh/qsl_F5IRH_111-3.JPG</image>
|
||||
<imageinfo>518:799:105108</imageinfo>
|
||||
<moddate>2010-08-05 01:05:37</moddate>
|
||||
<eqsl>0</eqsl>
|
||||
<mqsl>0</mqsl>
|
||||
<cqzone>14</cqzone>
|
||||
<iota>EU-048</iota>
|
||||
<lotw>0</lotw>
|
||||
<geoloc>user</geoloc>
|
||||
<name_fmt>AVRILLON Max</name_fmt>
|
||||
<serial>1570698</serial>
|
||||
</Callsign>
|
||||
<Session>
|
||||
<Key>e5ca5b3e7f88d733408ab7677e605270</Key>
|
||||
<Count>161639</Count>
|
||||
<SubExp>Sat Jul 3 21:45:38 2027</SubExp>
|
||||
<GMTime>Sun Aug 16 21:14:40 2026</GMTime>
|
||||
<Remark>cpu: 0.073s</Remark>
|
||||
</Session>
|
||||
</QRZDatabase>
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
- **A free QRZ account can never fill the locator, the coordinates, the zones
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package lookup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"hamlog/internal/db"
|
||||
)
|
||||
|
||||
func testCache(t *testing.T) *Cache {
|
||||
t.Helper()
|
||||
conn, err := db.Open(filepath.Join(t.TempDir(), "c.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { conn.Close() })
|
||||
return NewCache(conn, 0)
|
||||
}
|
||||
|
||||
// Adding a field to the cache leaves every row already in it without that field,
|
||||
// and the cache lasts thirty days. So a callsign looked up before the change
|
||||
// would go a MONTH without its island reference — which is exactly what the
|
||||
// first test of the feature ran into: a QRZ record plainly carrying
|
||||
// <iota>EU-048</iota>, and no IOTA on the entry.
|
||||
//
|
||||
// A row that predates the column is therefore treated as stale and refetched
|
||||
// once. NULL and "" mean different things here, and that is the whole mechanism.
|
||||
func TestCacheRefetchesRowsWrittenBeforeTheIOTAColumn(t *testing.T) {
|
||||
c := testCache(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// An operator with an island: stored and returned.
|
||||
if err := c.Put(ctx, Result{Callsign: "F5IRH", Name: "Max", IOTA: "EU-048", Source: "qrz"}); err != nil {
|
||||
t.Fatalf("put: %v", err)
|
||||
}
|
||||
got, ok := c.Get(ctx, "F5IRH")
|
||||
if !ok || got.IOTA != "EU-048" {
|
||||
t.Fatalf("Get = (%+v,%v), want the island back", got, ok)
|
||||
}
|
||||
|
||||
// An operator with NO island: an empty string is stored, and the row stays
|
||||
// usable. If this wrote NULL, every ordinary callsign would refetch for ever.
|
||||
if err := c.Put(ctx, Result{Callsign: "M0ABC", Name: "Ann", Source: "qrz"}); err != nil {
|
||||
t.Fatalf("put: %v", err)
|
||||
}
|
||||
got, ok = c.Get(ctx, "M0ABC")
|
||||
if !ok {
|
||||
t.Fatal("a callsign with no island was treated as stale — every lookup would repeat for ever")
|
||||
}
|
||||
if got.IOTA != "" {
|
||||
t.Errorf("IOTA = %q for an operator with no island", got.IOTA)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package lookup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A TTL of zero means no cache: nothing is read from it, and nothing is written
|
||||
// to it either.
|
||||
//
|
||||
// It is a real thing to want. An operator correcting their own QRZ record — or
|
||||
// chasing a DXpedition whose page changes during the operation — otherwise
|
||||
// waits out thirty days before OpsLog will ask again. Clearing the cache by
|
||||
// hand works once; switching it off is the setting for a session where the
|
||||
// answers are moving.
|
||||
func TestATTLOfZeroSwitchesTheCacheOff(t *testing.T) {
|
||||
c := testCache(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := c.Put(ctx, Result{Callsign: "M0ABC", Name: "Ann", Source: "qrz"}); err != nil {
|
||||
t.Fatalf("put: %v", err)
|
||||
}
|
||||
if _, ok := c.Get(ctx, "M0ABC"); !ok {
|
||||
t.Fatal("the cache did not hold a fresh entry while switched on")
|
||||
}
|
||||
|
||||
c.SetTTL(0)
|
||||
if c.Enabled() {
|
||||
t.Error("Enabled() is true with a zero TTL")
|
||||
}
|
||||
if _, ok := c.Get(ctx, "M0ABC"); ok {
|
||||
t.Error("a cached entry was still returned with the cache off — the provider would never be asked again")
|
||||
}
|
||||
// And nothing new is stored: those rows would only sit there going stale,
|
||||
// waiting for the day the cache is switched back on.
|
||||
if err := c.Put(ctx, Result{Callsign: "M0XYZ", Name: "Bob", Source: "qrz"}); err != nil {
|
||||
t.Fatalf("put with the cache off: %v", err)
|
||||
}
|
||||
c.SetTTL(30 * 24 * time.Hour)
|
||||
if _, ok := c.Get(ctx, "M0XYZ"); ok {
|
||||
t.Error("a lookup made while the cache was off was written to it anyway")
|
||||
}
|
||||
// The entry from before it was switched off is still there — switching off
|
||||
// is not the same as clearing, and the Clear cache button remains the way to
|
||||
// throw the contents away.
|
||||
if _, ok := c.Get(ctx, "M0ABC"); !ok {
|
||||
t.Error("switching the cache off discarded what it already held")
|
||||
}
|
||||
}
|
||||
|
||||
// A negative lifetime is meaningless, and rounding it into either "off" or a
|
||||
// default would be a guess. It is ignored instead.
|
||||
func TestANegativeTTLIsIgnored(t *testing.T) {
|
||||
c := testCache(t)
|
||||
c.SetTTL(7 * 24 * time.Hour)
|
||||
c.SetTTL(-1)
|
||||
if !c.Enabled() {
|
||||
t.Fatal("a negative TTL switched the cache off")
|
||||
}
|
||||
if c.ttl != 7*24*time.Hour {
|
||||
t.Errorf("ttl = %v after a negative value, want the 7 days it already had", c.ttl)
|
||||
}
|
||||
}
|
||||
|
||||
// The constructor's zero is the DEFAULT, not "off": at startup the settings
|
||||
// have not been read, and beginning with no cache would hammer the provider for
|
||||
// the first seconds of every launch.
|
||||
func TestNewCacheWithZeroStillCaches(t *testing.T) {
|
||||
c := testCache(t) // built with NewCache(conn, 0)
|
||||
if !c.Enabled() {
|
||||
t.Error("a cache built with a zero TTL started switched off")
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,10 @@ type Result struct {
|
||||
// along and nothing ever filled it, because no provider mapping read the
|
||||
// field.
|
||||
Web string `json:"web,omitempty"`
|
||||
// IOTA is the island reference (EU-048) for an operator on one. QRZ sends it
|
||||
// and nothing used to read it — and since no live activation feed exists for
|
||||
// IOTA the way it does for POTA, the callbook record is the practical source.
|
||||
IOTA string `json:"iota,omitempty"`
|
||||
// Zip is the postal code. HamQTH and QRZ both send one.
|
||||
Zip string `json:"zip,omitempty"`
|
||||
ImageURL string `json:"image_url,omitempty"` // profile picture URL
|
||||
@@ -428,11 +432,21 @@ func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
|
||||
// ----- Cache -----
|
||||
|
||||
// Cache is a SQLite-backed cache of lookup results with a TTL.
|
||||
//
|
||||
// A ttl of zero means NO CACHE: every lookup goes to the provider. That is a
|
||||
// real thing to want — an operator correcting their own QRZ record, or chasing
|
||||
// a DXpedition whose page changes during the operation, otherwise waits out the
|
||||
// cache before OpsLog will look again.
|
||||
type Cache struct {
|
||||
db *sql.DB
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
// NewCache builds the cache. A ttl of zero here is the CONSTRUCTOR default
|
||||
// (thirty days), not "off": at startup the settings have not been read yet, and
|
||||
// starting with no cache would hammer the provider for the first seconds of
|
||||
// every launch. Switching it off is a decision the operator makes, through
|
||||
// SetTTL, once their settings are known.
|
||||
func NewCache(db *sql.DB, ttl time.Duration) *Cache {
|
||||
if ttl <= 0 {
|
||||
ttl = 30 * 24 * time.Hour
|
||||
@@ -440,25 +454,35 @@ func NewCache(db *sql.DB, ttl time.Duration) *Cache {
|
||||
return &Cache{db: db, ttl: ttl}
|
||||
}
|
||||
|
||||
// SetTTL updates the cache TTL (e.g. when user changes settings).
|
||||
// SetTTL updates the cache lifetime.
|
||||
//
|
||||
// ZERO switches the cache OFF — nothing is read from it and nothing is written
|
||||
// to it. A NEGATIVE value is meaningless and is ignored, rather than being
|
||||
// rounded into one of the two meanings above.
|
||||
func (c *Cache) SetTTL(ttl time.Duration) {
|
||||
if ttl > 0 {
|
||||
if ttl >= 0 {
|
||||
c.ttl = ttl
|
||||
}
|
||||
}
|
||||
|
||||
// Enabled reports whether anything is being cached at all.
|
||||
func (c *Cache) Enabled() bool { return c != nil && c.ttl > 0 }
|
||||
|
||||
// Get returns the cached result if present and not expired.
|
||||
func (c *Cache) Get(ctx context.Context, callsign string) (Result, bool) {
|
||||
if !c.Enabled() {
|
||||
return Result{}, false
|
||||
}
|
||||
row := c.db.QueryRowContext(ctx, `
|
||||
SELECT callsign, name, qth, address, state, cnty, country, grid,
|
||||
lat, lon, dxcc, cqz, ituz, cont, email, qsl_via, image_url,
|
||||
web, zip, source, fetched_at
|
||||
web, zip, iota, source, fetched_at
|
||||
FROM callsign_cache WHERE callsign = ?`, callsign)
|
||||
var (
|
||||
r Result
|
||||
name, qth, addr, state, cnty sql.NullString
|
||||
country, grid, cont, email, qslVia, image sql.NullString
|
||||
web, zip sql.NullString
|
||||
web, zip, iotaRef sql.NullString
|
||||
src string
|
||||
dxcc, cqz, ituz sql.NullInt64
|
||||
lat, lon sql.NullFloat64
|
||||
@@ -466,7 +490,7 @@ func (c *Cache) Get(ctx context.Context, callsign string) (Result, bool) {
|
||||
)
|
||||
if err := row.Scan(&r.Callsign, &name, &qth, &addr, &state, &cnty,
|
||||
&country, &grid, &lat, &lon,
|
||||
&dxcc, &cqz, &ituz, &cont, &email, &qslVia, &image, &web, &zip,
|
||||
&dxcc, &cqz, &ituz, &cont, &email, &qslVia, &image, &web, &zip, &iotaRef,
|
||||
&src, &fetched); err != nil {
|
||||
return Result{}, false
|
||||
}
|
||||
@@ -477,6 +501,17 @@ func (c *Cache) Get(ctx context.Context, callsign string) (Result, bool) {
|
||||
if time.Since(t) > c.ttl {
|
||||
return Result{}, false
|
||||
}
|
||||
// A row written before the iota column existed has NULL there, and the cache
|
||||
// lasts thirty days — so without this every callsign already looked up would
|
||||
// go a month without its island reference, which is exactly what the first
|
||||
// test of the feature ran into.
|
||||
//
|
||||
// NULL and '' are deliberately different here: Put writes an empty string for
|
||||
// an operator with no island, so only a row that predates the column reads as
|
||||
// invalid. One refetch per such callsign, the next time it is used.
|
||||
if !iotaRef.Valid {
|
||||
return Result{}, false
|
||||
}
|
||||
r.Name = name.String
|
||||
r.QTH = qth.String
|
||||
r.Address = addr.String
|
||||
@@ -490,6 +525,7 @@ func (c *Cache) Get(ctx context.Context, callsign string) (Result, bool) {
|
||||
r.Email = email.String
|
||||
r.Web = web.String
|
||||
r.Zip = zip.String
|
||||
r.IOTA = strings.ToUpper(iotaRef.String)
|
||||
r.QSLVia = qslVia.String
|
||||
r.ImageURL = image.String
|
||||
r.DXCC = int(dxcc.Int64)
|
||||
@@ -503,10 +539,15 @@ func (c *Cache) Get(ctx context.Context, callsign string) (Result, bool) {
|
||||
// Put upserts a lookup result. fetched_at is generated in Go (NowISO) so the
|
||||
// INSERT is backend-agnostic; the conflict tail is dialect-specific.
|
||||
func (c *Cache) Put(ctx context.Context, r Result) error {
|
||||
if !c.Enabled() {
|
||||
// Nothing reads it, so writing would only grow the table — and leave
|
||||
// stale rows waiting for the day the cache is switched back on.
|
||||
return nil
|
||||
}
|
||||
updateCols := []string{
|
||||
"name", "qth", "address", "state", "cnty",
|
||||
"country", "grid", "lat", "lon",
|
||||
"dxcc", "cqz", "ituz", "cont", "email", "qsl_via", "image_url", "web", "zip",
|
||||
"dxcc", "cqz", "ituz", "cont", "email", "qsl_via", "image_url", "web", "zip", "iota",
|
||||
"source", "fetched_at",
|
||||
}
|
||||
// The lookup cache always lives in the local SQLite database, so SQLite
|
||||
@@ -519,8 +560,8 @@ func (c *Cache) Put(ctx context.Context, r Result) error {
|
||||
INSERT INTO callsign_cache(callsign, name, qth, address, state, cnty,
|
||||
country, grid, lat, lon,
|
||||
dxcc, cqz, ituz, cont, email, qsl_via, image_url,
|
||||
web, zip, source, fetched_at)
|
||||
VALUES(?,?,?,?,?,?, ?,?,?,?, ?,?,?,?,?,?,?, ?,?, ?,?)
|
||||
web, zip, iota, source, fetched_at)
|
||||
VALUES(?,?,?,?,?,?, ?,?,?,?, ?,?,?,?,?,?,?, ?,?,?, ?,?)
|
||||
ON CONFLICT(callsign) DO UPDATE SET ` + strings.Join(sets, ", ")
|
||||
_, err := c.db.ExecContext(ctx, q,
|
||||
r.Callsign, nullable(r.Name), nullable(r.QTH), nullable(r.Address),
|
||||
@@ -530,6 +571,9 @@ func (c *Cache) Put(ctx context.Context, r Result) error {
|
||||
nullableInt(r.DXCC), nullableInt(r.CQZ), nullableInt(r.ITUZ),
|
||||
nullable(r.Continent), nullable(r.Email), nullable(r.QSLVia),
|
||||
nullable(r.ImageURL), nullable(r.Web), nullable(r.Zip),
|
||||
// NOT nullable(): an operator with no island must store '', so that a NULL
|
||||
// keeps its one meaning — a row written before the column existed.
|
||||
r.IOTA,
|
||||
r.Source, db.NowISO(),
|
||||
)
|
||||
return err
|
||||
|
||||
@@ -134,6 +134,7 @@ func (q *QRZ) fetch(ctx context.Context, sessionKey, callsign string) (Result, e
|
||||
Email: c.Email,
|
||||
QSLVia: c.QSLMgr,
|
||||
ImageURL: strings.TrimSpace(c.Image),
|
||||
IOTA: strings.ToUpper(strings.TrimSpace(c.IOTA)),
|
||||
}
|
||||
r.Lat, _ = strconv.ParseFloat(c.Lat, 64)
|
||||
r.Lon, _ = strconv.ParseFloat(c.Lon, 64)
|
||||
@@ -193,6 +194,7 @@ type qrzCallsign struct {
|
||||
Email string `xml:"email"`
|
||||
QSLMgr string `xml:"qslmgr"`
|
||||
Image string `xml:"image"` // direct URL to the profile picture (subscribers only on QRZ)
|
||||
IOTA string `xml:"iota"` // island reference for an operator on one, e.g. EU-048
|
||||
}
|
||||
|
||||
// composeQRZAddress builds a multi-line postal address from QRZ's separate
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package lookup
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// QRZ sends the island reference and OpsLog read past it.
|
||||
//
|
||||
// It matters more for IOTA than it would for another award: unlike POTA there
|
||||
// is no live "who is on an island right now" feed anywhere, so the callbook
|
||||
// record is the practical source — and it is known BEFORE the contact is
|
||||
// logged, which is when it is useful.
|
||||
func TestQRZCallsignCarriesIOTA(t *testing.T) {
|
||||
const body = `<?xml version="1.0" encoding="utf-8" ?>
|
||||
<QRZDatabase version="1.34">
|
||||
<Callsign>
|
||||
<call>F5IRH</call>
|
||||
<fname>Max</fname>
|
||||
<country>France</country>
|
||||
<grid>IN87ki</grid>
|
||||
<cqzone>14</cqzone>
|
||||
<iota>EU-048</iota>
|
||||
</Callsign>
|
||||
<Session><Key>abc</Key></Session>
|
||||
</QRZDatabase>`
|
||||
|
||||
var resp qrzDB
|
||||
if err := xml.Unmarshal([]byte(body), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if resp.Callsign.IOTA != "EU-048" {
|
||||
t.Fatalf("the <iota> tag decoded as %q — the field is not being read", resp.Callsign.IOTA)
|
||||
}
|
||||
// And a record with no island must not invent one.
|
||||
var none qrzDB
|
||||
if err := xml.Unmarshal([]byte(strings.Replace(body, "<iota>EU-048</iota>", "", 1)), &none); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if none.Callsign.IOTA != "" {
|
||||
t.Errorf("IOTA = %q for a record without one", none.Callsign.IOTA)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
// Package psu drives a bench power supply over Modbus RTU — the BSIDE / Wanptek
|
||||
// family of programmable supplies that sit in a shack feeding the radios.
|
||||
//
|
||||
// The register map and the wire settings come from the manufacturer's own
|
||||
// document ("This machine only support function code 03,06", version 20180611):
|
||||
//
|
||||
// 9600 baud, 8 data bits, no parity, 1 stop bit
|
||||
// function 03 read holding registers
|
||||
// function 06 write single register
|
||||
// slave address 1…15, address 0 broadcast
|
||||
//
|
||||
// NOTHING ELSE IS WRITTEN. The map also carries the output voltage and current
|
||||
// SET points, and the over-voltage, over-current and over-power trip levels, all
|
||||
// read/write. This driver reads them and writes exactly one register: 0x0001,
|
||||
// the output on/off. A wrong value in any of the others is not a wrong reading —
|
||||
// it is 30 V where a radio expected 13.8, or a protection trip lifted on a
|
||||
// supply feeding an amplifier. There is no reason for a logbook to set them, so
|
||||
// it cannot.
|
||||
package psu
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Registers, from the manufacturer's table. Addresses are as printed there.
|
||||
const (
|
||||
regOnOff = 0x0001 // output on/off — 1 or 0. The ONLY register written.
|
||||
regProtect = 0x0002 // protection status word
|
||||
regModel = 0x0003 // specification model
|
||||
regDecimals = 0x0005 // "V_A_W number of digits" — see readDecimals
|
||||
regVolts = 0x0010 // measured output voltage, 2 decimals
|
||||
regAmps = 0x0011 // measured output current, 3 decimals
|
||||
regWatts = 0x0012 // measured output power, 32-bit across 0x0012/0x0013, 3 decimals
|
||||
regSetVolts = 0x0030 // voltage set point, 2 decimals
|
||||
regSetAmps = 0x0031 // current set point, 3 decimals
|
||||
)
|
||||
|
||||
const (
|
||||
fnRead = 0x03
|
||||
fnWrite = 0x06
|
||||
)
|
||||
|
||||
// Fixed scaling from the manufacturer's "Decimal place" column. The supply also
|
||||
// reports its own digit counts in 0x0005, but the document's "Note 2" that
|
||||
// explains how to decode that word is not in the manual we have — so the
|
||||
// documented per-register values are used, and the raw word is logged once at
|
||||
// connect. If an operator ever reports readings out by a factor of ten, that
|
||||
// line is what says how to decode it properly.
|
||||
const (
|
||||
voltScale = 100.0 // 2 decimals
|
||||
ampScale = 1000.0 // 3 decimals
|
||||
wattScale = 1000.0 // 3 decimals
|
||||
)
|
||||
|
||||
// crc16 is the Modbus RTU frame check: CRC-16/MODBUS — reflected, polynomial
|
||||
// 0xA001, initial value 0xFFFF, no final xor. Transmitted low byte first.
|
||||
func crc16(b []byte) uint16 {
|
||||
crc := uint16(0xFFFF)
|
||||
for _, c := range b {
|
||||
crc ^= uint16(c)
|
||||
for i := 0; i < 8; i++ {
|
||||
if crc&1 != 0 {
|
||||
crc = (crc >> 1) ^ 0xA001
|
||||
} else {
|
||||
crc >>= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return crc
|
||||
}
|
||||
|
||||
// appendCRC closes a frame: low byte first, as the manual states.
|
||||
func appendCRC(f []byte) []byte {
|
||||
c := crc16(f)
|
||||
return append(f, byte(c&0xFF), byte(c>>8))
|
||||
}
|
||||
|
||||
// buildRead frames a function 03 "read holding registers".
|
||||
func buildRead(addr byte, reg uint16, count uint16) []byte {
|
||||
f := []byte{addr, fnRead, byte(reg >> 8), byte(reg), byte(count >> 8), byte(count)}
|
||||
return appendCRC(f)
|
||||
}
|
||||
|
||||
// buildWrite frames a function 06 "write single register".
|
||||
func buildWrite(addr byte, reg, val uint16) []byte {
|
||||
f := []byte{addr, fnWrite, byte(reg >> 8), byte(reg), byte(val >> 8), byte(val)}
|
||||
return appendCRC(f)
|
||||
}
|
||||
|
||||
// modbusError is an exception response — the supply understood the frame and
|
||||
// refused it. Kept distinct from a transport failure: one means "ask
|
||||
// differently", the other means "the cable".
|
||||
type modbusError struct {
|
||||
fn byte
|
||||
code byte
|
||||
}
|
||||
|
||||
func (e modbusError) Error() string {
|
||||
what := map[byte]string{
|
||||
1: "illegal function",
|
||||
2: "illegal data address",
|
||||
3: "illegal data value",
|
||||
4: "slave device failure",
|
||||
6: "device busy",
|
||||
}[e.code]
|
||||
if what == "" {
|
||||
what = fmt.Sprintf("exception %d", e.code)
|
||||
}
|
||||
return fmt.Sprintf("supply refused function 0x%02X: %s", e.fn, what)
|
||||
}
|
||||
|
||||
// parseRead validates a function 03 reply and returns the register values.
|
||||
func parseRead(addr byte, want uint16, frame []byte) ([]uint16, error) {
|
||||
if err := checkFrame(addr, fnRead, frame, 5); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n := int(frame[2])
|
||||
if n != int(want)*2 {
|
||||
return nil, fmt.Errorf("reply carries %d data byte(s), expected %d", n, want*2)
|
||||
}
|
||||
if len(frame) != 3+n+2 {
|
||||
return nil, fmt.Errorf("reply is %d bytes, expected %d", len(frame), 3+n+2)
|
||||
}
|
||||
out := make([]uint16, want)
|
||||
for i := range out {
|
||||
out[i] = binary.BigEndian.Uint16(frame[3+i*2:])
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// parseWriteEcho validates a function 06 reply, which echoes the request.
|
||||
func parseWriteEcho(addr byte, reg, val uint16, frame []byte) error {
|
||||
if err := checkFrame(addr, fnWrite, frame, 8); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(frame) != 8 {
|
||||
return fmt.Errorf("write reply is %d bytes, expected 8", len(frame))
|
||||
}
|
||||
if got := binary.BigEndian.Uint16(frame[2:]); got != reg {
|
||||
return fmt.Errorf("write reply is for register 0x%04X, not 0x%04X", got, reg)
|
||||
}
|
||||
// The echoed VALUE is the confirmation that the output actually changed.
|
||||
// Accepting the frame without checking it would report an on/off that the
|
||||
// supply never made.
|
||||
if got := binary.BigEndian.Uint16(frame[4:]); got != val {
|
||||
return fmt.Errorf("supply echoed value %d, not the %d it was sent", got, val)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkFrame covers what every reply must satisfy: our address, our function
|
||||
// (or its exception), and a good CRC.
|
||||
func checkFrame(addr, fn byte, frame []byte, min int) error {
|
||||
if len(frame) < 4 {
|
||||
return fmt.Errorf("short reply (%d bytes)", len(frame))
|
||||
}
|
||||
if frame[0] != addr {
|
||||
return fmt.Errorf("reply from address %d, expected %d", frame[0], addr)
|
||||
}
|
||||
if frame[1] == fn|0x80 {
|
||||
if len(frame) < 5 {
|
||||
return fmt.Errorf("short exception reply (%d bytes)", len(frame))
|
||||
}
|
||||
if !crcOK(frame[:5]) {
|
||||
return fmt.Errorf("exception reply failed its CRC")
|
||||
}
|
||||
return modbusError{fn: fn, code: frame[2]}
|
||||
}
|
||||
if frame[1] != fn {
|
||||
return fmt.Errorf("reply to function 0x%02X, expected 0x%02X", frame[1], fn)
|
||||
}
|
||||
if len(frame) < min {
|
||||
return fmt.Errorf("short reply (%d bytes, expected at least %d)", len(frame), min)
|
||||
}
|
||||
if !crcOK(frame) {
|
||||
return fmt.Errorf("reply failed its CRC")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// crcOK checks a whole frame, trailing CRC included: the CRC of the entire
|
||||
// frame is zero when it is intact.
|
||||
func crcOK(frame []byte) bool {
|
||||
if len(frame) < 3 {
|
||||
return false
|
||||
}
|
||||
body := frame[:len(frame)-2]
|
||||
want := uint16(frame[len(frame)-2]) | uint16(frame[len(frame)-1])<<8
|
||||
return crc16(body) == want
|
||||
}
|
||||
|
||||
// frameGap is the silence that separates two Modbus RTU frames: 3.5 character
|
||||
// times, which at 9600 baud 8N1 (10 bits per character) is 3.65 ms. Rounded up,
|
||||
// because the cost of waiting is nothing and the cost of being early is a
|
||||
// supply that treats our request as the tail of the previous one.
|
||||
const frameGap = 4 * time.Millisecond
|
||||
|
||||
// replyWait is how long a reply may take. The manual promises under 5 ms at
|
||||
// 9600 baud or better; this is generous by two orders of magnitude so a USB
|
||||
// serial bridge that buffers cannot be mistaken for a supply that is not there.
|
||||
const replyWait = 500 * time.Millisecond
|
||||
|
||||
// readFrame collects a reply until it stops arriving.
|
||||
//
|
||||
// A serial read that times out returns (0, nil) on Windows — a timeout is not
|
||||
// an error on this transport — so a loop that trusts an error to end it never
|
||||
// ends. Modbus RTU has no terminator either: a frame is over when the line has
|
||||
// been quiet for 3.5 character times. Both facts point at the same shape, a
|
||||
// deadline and a quiet-time.
|
||||
func readFrame(conn io.Reader, d time.Duration) ([]byte, error) {
|
||||
deadline := time.Now().Add(d)
|
||||
buf := make([]byte, 0, 64)
|
||||
tmp := make([]byte, 64)
|
||||
lastByte := time.Time{}
|
||||
for {
|
||||
n, err := conn.Read(tmp)
|
||||
if err != nil {
|
||||
return buf, err
|
||||
}
|
||||
if n > 0 {
|
||||
buf = append(buf, tmp[:n]...)
|
||||
lastByte = time.Now()
|
||||
continue
|
||||
}
|
||||
// Nothing this time: either the frame has ended, or it never started.
|
||||
if len(buf) > 0 && time.Since(lastByte) >= frameGap {
|
||||
return buf, nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
if len(buf) > 0 {
|
||||
return buf, nil // partial — let the parser say what is wrong with it
|
||||
}
|
||||
return nil, fmt.Errorf("no reply after %s", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package psu
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The CRC is the one thing here that cannot be checked by inspection, and every
|
||||
// frame depends on it. CRC-16/MODBUS has a published check value: the CRC of
|
||||
// the ASCII digits "123456789" is 0x4B37. If this passes, the polynomial, the
|
||||
// initial value, the reflection and the absence of a final xor are all right.
|
||||
func TestCRCMatchesTheStandardCheckValue(t *testing.T) {
|
||||
if got := crc16([]byte("123456789")); got != 0x4B37 {
|
||||
t.Fatalf("crc16(\"123456789\") = 0x%04X, want 0x4B37 — this is not CRC-16/MODBUS", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A frame including its own CRC checks to zero. That property is what crcOK
|
||||
// relies on, so it is worth pinning separately from the check value.
|
||||
func TestAFrameVerifiesItself(t *testing.T) {
|
||||
for _, f := range [][]byte{
|
||||
buildRead(1, regVolts, 2),
|
||||
buildWrite(1, regOnOff, 1),
|
||||
buildWrite(15, regOnOff, 0),
|
||||
} {
|
||||
if !crcOK(f) {
|
||||
t.Errorf("% X does not verify against its own CRC", f)
|
||||
}
|
||||
// And a single flipped bit must be caught.
|
||||
bad := append([]byte(nil), f...)
|
||||
bad[2] ^= 0x01
|
||||
if crcOK(bad) {
|
||||
t.Errorf("% X passed the CRC with a corrupted byte", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The frame layout, byte for byte against the manual: address, function,
|
||||
// register high/low, count or value high/low, then CRC low byte first.
|
||||
func TestFrameLayout(t *testing.T) {
|
||||
r := buildRead(1, 0x0010, 2)
|
||||
if len(r) != 8 {
|
||||
t.Fatalf("read frame is %d bytes, want 8", len(r))
|
||||
}
|
||||
want := []byte{0x01, 0x03, 0x00, 0x10, 0x00, 0x02}
|
||||
for i := range want {
|
||||
if r[i] != want[i] {
|
||||
t.Fatalf("read frame % X, want % X…", r, want)
|
||||
}
|
||||
}
|
||||
w := buildWrite(1, regOnOff, 1)
|
||||
want = []byte{0x01, 0x06, 0x00, 0x01, 0x00, 0x01}
|
||||
for i := range want {
|
||||
if w[i] != want[i] {
|
||||
t.Fatalf("write frame % X, want % X…", w, want)
|
||||
}
|
||||
}
|
||||
// The CRC goes out low byte first — the manual is explicit, and getting it
|
||||
// backwards makes every frame be ignored in silence.
|
||||
c := crc16(w[:6])
|
||||
if w[6] != byte(c&0xFF) || w[7] != byte(c>>8) {
|
||||
t.Errorf("CRC bytes % X, want %02X %02X (low first)", w[6:], byte(c&0xFF), byte(c>>8))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRead(t *testing.T) {
|
||||
// Two registers: 13.80 V (1380 at 2 decimals) and 2.500 A (2500 at 3).
|
||||
frame := appendCRC([]byte{0x01, 0x03, 0x04, 0x05, 0x64, 0x09, 0xC4})
|
||||
got, err := parseRead(1, 2, frame)
|
||||
if err != nil {
|
||||
t.Fatalf("parseRead: %v", err)
|
||||
}
|
||||
if len(got) != 2 || got[0] != 1380 || got[1] != 2500 {
|
||||
t.Fatalf("got %v, want [1380 2500]", got)
|
||||
}
|
||||
if v := float64(got[0]) / voltScale; v != 13.80 {
|
||||
t.Errorf("voltage scaled to %v, want 13.8", v)
|
||||
}
|
||||
}
|
||||
|
||||
// A reply from another slave on the same bus must not be read as ours.
|
||||
func TestParseRejectsAnotherSlave(t *testing.T) {
|
||||
frame := appendCRC([]byte{0x02, 0x03, 0x02, 0x05, 0x64})
|
||||
if _, err := parseRead(1, 1, frame); err == nil {
|
||||
t.Error("a reply from address 2 was accepted as address 1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRejectsABadCRC(t *testing.T) {
|
||||
frame := appendCRC([]byte{0x01, 0x03, 0x02, 0x05, 0x64})
|
||||
frame[3] ^= 0xFF
|
||||
if _, err := parseRead(1, 1, frame); err == nil || !strings.Contains(err.Error(), "CRC") {
|
||||
t.Errorf("err = %v, want a CRC complaint", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An exception reply is the supply refusing, not the line failing, and the two
|
||||
// need different answers from the operator.
|
||||
func TestParseReportsAnException(t *testing.T) {
|
||||
frame := appendCRC([]byte{0x01, 0x83, 0x02})
|
||||
_, err := parseRead(1, 1, frame)
|
||||
var me modbusError
|
||||
if !errors.As(err, &me) {
|
||||
t.Fatalf("err = %v, want a modbusError", err)
|
||||
}
|
||||
if me.code != 2 || !strings.Contains(err.Error(), "illegal data address") {
|
||||
t.Errorf("exception decoded as %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The echo is the ONLY confirmation that the output actually switched. A reply
|
||||
// echoing a different value means the supply did something else, and reporting
|
||||
// that as success is how a radio ends up with no power and a green light.
|
||||
func TestWriteEchoMustMatch(t *testing.T) {
|
||||
ok := appendCRC([]byte{0x01, 0x06, 0x00, 0x01, 0x00, 0x01})
|
||||
if err := parseWriteEcho(1, regOnOff, 1, ok); err != nil {
|
||||
t.Fatalf("a correct echo was rejected: %v", err)
|
||||
}
|
||||
wrongVal := appendCRC([]byte{0x01, 0x06, 0x00, 0x01, 0x00, 0x00})
|
||||
if err := parseWriteEcho(1, regOnOff, 1, wrongVal); err == nil {
|
||||
t.Error("an echo of 0 was accepted for a command of 1 — the output never switched")
|
||||
}
|
||||
wrongReg := appendCRC([]byte{0x01, 0x06, 0x00, 0x30, 0x00, 0x01})
|
||||
if err := parseWriteEcho(1, regOnOff, 1, wrongReg); err == nil {
|
||||
t.Error("an echo for register 0x0030 was accepted for a write to 0x0001")
|
||||
}
|
||||
}
|
||||
|
||||
// quietPort delivers a frame in pieces, then goes quiet — a serial port that
|
||||
// reports a timeout as (0, nil), which is what Windows does.
|
||||
type quietPort struct {
|
||||
chunks [][]byte
|
||||
i int
|
||||
}
|
||||
|
||||
func (p *quietPort) Read(b []byte) (int, error) {
|
||||
if p.i >= len(p.chunks) {
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
return 0, nil // timeout, not an error
|
||||
}
|
||||
n := copy(b, p.chunks[p.i])
|
||||
p.i++
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// A Modbus RTU frame has no terminator: it ends when the line falls quiet. The
|
||||
// reader must assemble a dribbled frame and then stop on its own.
|
||||
func TestReadFrameAssemblesUntilQuiet(t *testing.T) {
|
||||
want := appendCRC([]byte{0x01, 0x03, 0x04, 0x05, 0x64, 0x09, 0xC4})
|
||||
p := &quietPort{chunks: [][]byte{want[:2], want[2:5], want[5:]}}
|
||||
got, err := readFrame(p, time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("readFrame: %v", err)
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("read % X, want % X", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("read % X, want % X", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A supply that is switched off, or not on this port, must produce an error
|
||||
// rather than a wait that never ends.
|
||||
func TestReadFrameGivesUpOnSilence(t *testing.T) {
|
||||
done := make(chan error, 1)
|
||||
go func() { _, err := readFrame(&quietPort{}, 80*time.Millisecond); done <- err }()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err == nil {
|
||||
t.Error("silence was reported as a frame")
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("readFrame never returned")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package psu
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.bug.st/serial"
|
||||
)
|
||||
|
||||
// Config is one supply's serial link.
|
||||
type Config struct {
|
||||
ComPort string
|
||||
Baud int // 9600 unless the supply has been reconfigured
|
||||
Address byte // Modbus slave address, 1…15 on this family
|
||||
}
|
||||
|
||||
// Status is what the UI shows. Everything here is READ from the supply — the
|
||||
// set points included, which the operator sets on the front panel and OpsLog
|
||||
// only reports.
|
||||
type Status struct {
|
||||
Connected bool `json:"connected"`
|
||||
On bool `json:"on"` // output enabled
|
||||
Volts float64 `json:"volts"` // measured output
|
||||
Amps float64 `json:"amps"` // measured output
|
||||
Watts float64 `json:"watts"` // measured output
|
||||
SetVolts float64 `json:"set_volts"` // the voltage the supply is set to
|
||||
SetAmps float64 `json:"set_amps"` // the current limit it is set to
|
||||
Protected uint16 `json:"protected"` // protection status word, non-zero = tripped
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
const pollEvery = 1500 * time.Millisecond
|
||||
|
||||
// Client owns the serial link to one supply.
|
||||
type Client struct {
|
||||
cfg Config
|
||||
|
||||
connMu sync.Mutex
|
||||
conn serial.Port
|
||||
|
||||
ioMu sync.Mutex // serialises a request/reply exchange on the shared port
|
||||
|
||||
statusMu sync.Mutex
|
||||
last Status
|
||||
|
||||
stopChan chan struct{}
|
||||
stopOnce sync.Once
|
||||
lastErr string
|
||||
}
|
||||
|
||||
// New builds a client. Nothing is opened until Start.
|
||||
func New(cfg Config) *Client {
|
||||
if cfg.Baud <= 0 {
|
||||
cfg.Baud = 9600
|
||||
}
|
||||
if cfg.Address == 0 {
|
||||
cfg.Address = 1
|
||||
}
|
||||
return &Client{cfg: cfg, stopChan: make(chan struct{})}
|
||||
}
|
||||
|
||||
// Start begins the poll loop. It returns immediately: the supply may be off, and
|
||||
// a shack comes up in whatever order it comes up in.
|
||||
func (c *Client) Start() error {
|
||||
go c.loop()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop closes the link.
|
||||
func (c *Client) Stop() {
|
||||
c.stopOnce.Do(func() { close(c.stopChan) })
|
||||
c.closeConn()
|
||||
}
|
||||
|
||||
// GetStatus returns the last poll's answer.
|
||||
func (c *Client) GetStatus() Status {
|
||||
c.statusMu.Lock()
|
||||
defer c.statusMu.Unlock()
|
||||
return c.last
|
||||
}
|
||||
|
||||
// SetOutput switches the supply's output on or off — the one thing this driver
|
||||
// writes. The supply's echo is checked, so a false return of "done" is not
|
||||
// possible: either it confirmed the value or this is an error.
|
||||
func (c *Client) SetOutput(on bool) error {
|
||||
val := uint16(0)
|
||||
if on {
|
||||
val = 1
|
||||
}
|
||||
if err := c.writeRegister(regOnOff, val); err != nil {
|
||||
return err
|
||||
}
|
||||
// Report it at once rather than waiting for the next poll: the operator
|
||||
// pressed a button and is looking at it.
|
||||
c.statusMu.Lock()
|
||||
c.last.On = on
|
||||
c.statusMu.Unlock()
|
||||
log.Printf("psu: output %s", map[bool]string{true: "ON", false: "OFF"}[on])
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) loop() {
|
||||
t := time.NewTicker(pollEvery)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-c.stopChan:
|
||||
return
|
||||
case <-t.C:
|
||||
if err := c.poll(); err != nil {
|
||||
c.noteFailure(err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// poll reads everything the UI shows, in as few exchanges as the register map
|
||||
// allows: the measurements are contiguous (0x0010…0x0013), the set points are
|
||||
// contiguous (0x0030, 0x0031), and the on/off and protection words sit together
|
||||
// at 0x0001/0x0002.
|
||||
func (c *Client) poll() error {
|
||||
if err := c.ensureConn(); err != nil {
|
||||
return err
|
||||
}
|
||||
st := Status{Connected: true}
|
||||
|
||||
state, err := c.readRegisters(regOnOff, 2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
st.On = state[0] != 0
|
||||
st.Protected = state[1]
|
||||
|
||||
meas, err := c.readRegisters(regVolts, 4) // U, I, P high, P low
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
st.Volts = float64(meas[0]) / voltScale
|
||||
st.Amps = float64(meas[1]) / ampScale
|
||||
st.Watts = float64(uint32(meas[2])<<16|uint32(meas[3])) / wattScale
|
||||
|
||||
set, err := c.readRegisters(regSetVolts, 2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
st.SetVolts = float64(set[0]) / voltScale
|
||||
st.SetAmps = float64(set[1]) / ampScale
|
||||
|
||||
c.statusMu.Lock()
|
||||
c.last = st
|
||||
c.statusMu.Unlock()
|
||||
if c.lastErr != "" {
|
||||
log.Printf("psu: %s answering again", c.cfg.ComPort)
|
||||
c.lastErr = ""
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) readRegisters(reg, count uint16) ([]uint16, error) {
|
||||
frame, err := c.exchange(buildRead(c.cfg.Address, reg, count))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseRead(c.cfg.Address, count, frame)
|
||||
}
|
||||
|
||||
func (c *Client) writeRegister(reg, val uint16) error {
|
||||
if err := c.ensureConn(); err != nil {
|
||||
return err
|
||||
}
|
||||
frame, err := c.exchange(buildWrite(c.cfg.Address, reg, val))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return parseWriteEcho(c.cfg.Address, reg, val, frame)
|
||||
}
|
||||
|
||||
// exchange sends one frame and reads one reply, holding the port for the whole
|
||||
// round trip. Modbus RTU has no way to match a reply to a request, so two
|
||||
// exchanges in flight at once would read each other's answers.
|
||||
func (c *Client) exchange(req []byte) ([]byte, error) {
|
||||
c.connMu.Lock()
|
||||
conn := c.conn
|
||||
c.connMu.Unlock()
|
||||
if conn == nil {
|
||||
return nil, fmt.Errorf("psu: not connected")
|
||||
}
|
||||
|
||||
c.ioMu.Lock()
|
||||
defer c.ioMu.Unlock()
|
||||
|
||||
// The silence before a frame is part of the protocol, not politeness: it is
|
||||
// how the supply knows this is a new message and not the tail of the last.
|
||||
time.Sleep(frameGap)
|
||||
if _, err := conn.Write(req); err != nil {
|
||||
c.closeConn()
|
||||
return nil, err
|
||||
}
|
||||
frame, err := readFrame(conn, replyWait)
|
||||
if err != nil {
|
||||
c.closeConn()
|
||||
return nil, err
|
||||
}
|
||||
return frame, nil
|
||||
}
|
||||
|
||||
func (c *Client) ensureConn() error {
|
||||
c.connMu.Lock()
|
||||
defer c.connMu.Unlock()
|
||||
if c.conn != nil {
|
||||
return nil
|
||||
}
|
||||
port := strings.TrimSpace(c.cfg.ComPort)
|
||||
if port == "" {
|
||||
return fmt.Errorf("psu: no serial port configured")
|
||||
}
|
||||
p, err := serial.Open(port, &serial.Mode{
|
||||
BaudRate: c.cfg.Baud,
|
||||
DataBits: 8,
|
||||
Parity: serial.NoParity,
|
||||
StopBits: serial.OneStopBit,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("psu: cannot open %s: %w", port, err)
|
||||
}
|
||||
// Short per-read timeout: readFrame decides when a frame has ended by the
|
||||
// quiet between bytes, so each Read must come back promptly with whatever
|
||||
// has arrived.
|
||||
_ = p.SetReadTimeout(2 * time.Millisecond)
|
||||
c.conn = p
|
||||
log.Printf("psu: %s open at %d baud, Modbus address %d", port, c.cfg.Baud, c.cfg.Address)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) closeConn() {
|
||||
c.connMu.Lock()
|
||||
defer c.connMu.Unlock()
|
||||
if c.conn != nil {
|
||||
_ = c.conn.Close()
|
||||
c.conn = nil
|
||||
}
|
||||
}
|
||||
|
||||
// noteFailure records a poll failure and says so ONCE per distinct message.
|
||||
//
|
||||
// A supply that is switched off at the mains fails every poll, and a line per
|
||||
// second and a half would be the whole log file — but saying nothing at all is
|
||||
// how "it stopped working" arrives with no evidence.
|
||||
func (c *Client) noteFailure(err error) {
|
||||
msg := err.Error()
|
||||
c.statusMu.Lock()
|
||||
c.last = Status{Connected: false, Error: msg}
|
||||
c.statusMu.Unlock()
|
||||
if msg != c.lastErr {
|
||||
c.lastErr = msg
|
||||
log.Printf("psu: %v — retrying every %s, and this will not be logged again until it changes", err, pollEvery)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,32 @@ package qso
|
||||
|
||||
import "testing"
|
||||
|
||||
// baseCall picks the operator's OWN callsign out of a portable form. The slash
|
||||
// carries a qualifier on either side and which side depends on what it is, so
|
||||
// this is the whole difficulty.
|
||||
func TestBaseCall(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"RK3DWA": "RK3DWA",
|
||||
"RK3DWA/3": "RK3DWA", // call-area digit
|
||||
"RK3DWA/P": "RK3DWA", // portable
|
||||
"RK3DWA/QRP": "RK3DWA",
|
||||
"RK3DWA/MM": "RK3DWA",
|
||||
"ZA/OE8NDR": "OE8NDR", // an Austrian in Albania — the case that prompted this
|
||||
"ZA/IZ2DPX": "IZ2DPX",
|
||||
"F/DL1ABC": "DL1ABC",
|
||||
"F/DL1ABC/P": "DL1ABC",
|
||||
"KH6/K6ABC": "K6ABC",
|
||||
"VP2E/W1ABC": "W1ABC",
|
||||
"3DA0/ZS1ABC": "ZS1ABC",
|
||||
"9A/S51AB": "S51AB",
|
||||
"/RK3DWA": "RK3DWA",
|
||||
} {
|
||||
if got := baseCall(in); got != want {
|
||||
t.Errorf("baseCall(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The predicate behind "Worked before". Exact when folding is off; with it on,
|
||||
// a station's portable forms are one operator — and the fold has to work from
|
||||
// either end, because you may type the base call or the portable one.
|
||||
@@ -10,25 +36,61 @@ func TestCallMatch(t *testing.T) {
|
||||
t.Errorf("exact: got %q %v", pred, args)
|
||||
}
|
||||
|
||||
// Typing the base call: match it and everything suffixed off it.
|
||||
pred, args := callMatch("RK3DWA", true)
|
||||
if pred != "(callsign = ? OR callsign LIKE ?)" {
|
||||
t.Errorf("variants predicate = %q", pred)
|
||||
}
|
||||
if len(args) != 2 || args[0] != "RK3DWA" || args[1] != "RK3DWA/%" {
|
||||
t.Errorf("variants args = %v, want [RK3DWA RK3DWA/%%]", args)
|
||||
}
|
||||
// Typing the base call: match it, everything suffixed off it, and every
|
||||
// prefixed form of it.
|
||||
_, args := callMatch("RK3DWA", true)
|
||||
want := []any{"RK3DWA", "RK3DWA/%", "%/RK3DWA", "%/RK3DWA/%"}
|
||||
assertArgs(t, "base call", args, want)
|
||||
|
||||
// Typing a portable form must reach the plain call too — the suffix is
|
||||
// Typing a portable form must reach the plain call too — the qualifier is
|
||||
// stripped from the INPUT, not just matched in the column.
|
||||
_, args = callMatch("RK3DWA/3", true)
|
||||
if len(args) != 2 || args[0] != "RK3DWA" || args[1] != "RK3DWA/%" {
|
||||
t.Errorf("portable input args = %v, want [RK3DWA RK3DWA/%%]", args)
|
||||
assertArgs(t, "portable input", args, want)
|
||||
}
|
||||
|
||||
// A prefixed call is the same operator, and NOT every other visitor to that
|
||||
// country.
|
||||
//
|
||||
// Matching on the part before the slash made ZA/OE8NDR the station "ZA", so the
|
||||
// worked-before list for one Austrian operating from Albania showed every other
|
||||
// ZA/ guest — two Italians — and counted them as four contacts "with this
|
||||
// call". Reported from a screenshot of exactly that.
|
||||
func TestCallMatchPrefixedIsTheOperatorNotTheCountry(t *testing.T) {
|
||||
_, args := callMatch("ZA/OE8NDR", true)
|
||||
assertArgs(t, "ZA/OE8NDR", args, []any{"OE8NDR", "OE8NDR/%", "%/OE8NDR", "%/OE8NDR/%"})
|
||||
|
||||
for _, a := range args {
|
||||
if s, _ := a.(string); s == "ZA" || s == "ZA/%" {
|
||||
t.Fatalf("the country prefix is still being matched as a station: %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
// A leading slash is not a suffix marker — dropping to "" there would match
|
||||
// the entire logbook.
|
||||
if _, args := callMatch("/RK3DWA", true); args[0] != "/RK3DWA" {
|
||||
t.Errorf("leading slash: args[0] = %v, want the call unchanged", args[0])
|
||||
// And the two are not each other: nothing in one operator's predicate can
|
||||
// select the other's call.
|
||||
_, other := callMatch("ZA/IZ2DPX", true)
|
||||
assertArgs(t, "ZA/IZ2DPX", other, []any{"IZ2DPX", "IZ2DPX/%", "%/IZ2DPX", "%/IZ2DPX/%"})
|
||||
}
|
||||
|
||||
// A base too short to be a callsign falls back to an exact match. "F/DL1ABC"
|
||||
// resolves fine, but a malformed entry must never produce a LIKE that selects
|
||||
// half the logbook.
|
||||
func TestCallMatchRefusesAShortBase(t *testing.T) {
|
||||
for _, call := range []string{"F/", "9A", "/P", "K/M"} {
|
||||
pred, args := callMatch(call, true)
|
||||
if pred != "callsign = ?" || len(args) != 1 {
|
||||
t.Errorf("%q produced %q %v — want an exact match", call, pred, args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertArgs(t *testing.T, what string, got, want []any) {
|
||||
t.Helper()
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("%s: args %v, want %v", what, got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("%s: args %v, want %v", what, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+131
-8
@@ -215,6 +215,12 @@ type QSO struct {
|
||||
// that doesn't know about it can't clobber it.
|
||||
AwardRefs string `json:"award_refs,omitempty"`
|
||||
|
||||
// SyncUID is this contact's stable identity for folder synchronisation —
|
||||
// what lets another machine name the same QSO when it edits or deletes it.
|
||||
// Like AwardRefs it is read here but NOT in columnList, so an ordinary edit
|
||||
// can never clobber it; it is written only by SetSyncUID / BackfillSyncUIDs.
|
||||
SyncUID string `json:"sync_uid,omitempty"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -271,7 +277,7 @@ const columnList = `callsign, qso_date, qso_date_off, band, band_rx, mode, submo
|
||||
// award_refs is read here but is NOT part of columnList (the insert/update
|
||||
// write path) — it is a derived cache written only via SetAwardRefs, so a
|
||||
// normal QSO write can never clobber it.
|
||||
const selectCols = `id, ` + columnList + `, award_refs, created_at, updated_at`
|
||||
const selectCols = `id, ` + columnList + `, award_refs, sync_uid, created_at, updated_at`
|
||||
|
||||
// columnCount is derived from columnList at init so they can never drift.
|
||||
var columnCount = countColumns(columnList)
|
||||
@@ -1723,26 +1729,77 @@ type BandMode struct {
|
||||
// rendering a recent-contacts mini-list.
|
||||
const maxWorkedEntries = 50
|
||||
|
||||
// operatorSuffixes are the appendages that qualify a callsign without changing
|
||||
// whose it is. A bare digit (RK3DWA/3) counts too, handled separately.
|
||||
var operatorSuffixes = map[string]bool{
|
||||
"P": true, "M": true, "MM": true, "AM": true, "QRP": true,
|
||||
"A": true, "B": true, "J": true, "LH": true, "R": true, "T": true,
|
||||
}
|
||||
|
||||
// baseCall extracts the operator's OWN callsign from a portable form.
|
||||
//
|
||||
// The slash carries the qualifier on either side, and which side depends on
|
||||
// what it is: RK3DWA/3 and RK3DWA/P append to the call, while ZA/OE8NDR and
|
||||
// F/DL1ABC put a country prefix in front of it. Taking the part before the
|
||||
// slash — which is what this used to do — reads ZA/OE8NDR as the station "ZA".
|
||||
//
|
||||
// So: drop the known qualifiers, and of what is left take the longest part. A
|
||||
// prefix is short by nature (ZA, F, KH6, VP2E) and a callsign is not.
|
||||
func baseCall(call string) string {
|
||||
if !strings.Contains(call, "/") {
|
||||
return call
|
||||
}
|
||||
best := ""
|
||||
for _, p := range strings.Split(call, "/") {
|
||||
if p == "" || operatorSuffixes[p] {
|
||||
continue
|
||||
}
|
||||
if len(p) == 1 && p[0] >= '0' && p[0] <= '9' {
|
||||
continue // the call-area digit
|
||||
}
|
||||
if len(p) > len(best) {
|
||||
best = p
|
||||
}
|
||||
}
|
||||
if best == "" {
|
||||
return call
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// callMatch builds the WHERE fragment that selects one station's QSOs.
|
||||
//
|
||||
// Exact by default. With variants on, an operator's portable forms count as the
|
||||
// same station: RK3DWA, RK3DWA/3, RK3DWA/P and RK3DWA/QRP are one person, and
|
||||
// someone asking "have I worked RK3DWA?" means the person, not the string.
|
||||
// Typing 21 QSOs' worth of history only when you happen to add "/3" is the
|
||||
// behaviour this replaces. The suffix is stripped from what was TYPED too, so
|
||||
// it matches both ways round — RK3DWA/3 also finds the plain RK3DWA contacts.
|
||||
// behaviour this replaces.
|
||||
//
|
||||
// PREFIXED FORMS MATCH TOO, and getting that wrong is what prompted this.
|
||||
// Matching on the part before the slash turned ZA/OE8NDR into the station "ZA",
|
||||
// so the worked-before list for one Austrian operating from Albania showed
|
||||
// every OTHER visitor to Albania — two Italians and himself — and counted them
|
||||
// as four contacts "with this call".
|
||||
//
|
||||
// Deliberately NOT a bare "starts with": LIKE 'RK3DWA%' would also drag in
|
||||
// RK3DWAB, which is a different station. The '/' is what makes it the same one.
|
||||
// And the variant match is only used on a base of three characters or more —
|
||||
// below that a prefix could match half the log.
|
||||
func callMatch(call string, variants bool) (string, []any) {
|
||||
if !variants {
|
||||
return "callsign = ?", []any{call}
|
||||
}
|
||||
base := call
|
||||
if i := strings.IndexByte(base, '/'); i > 0 {
|
||||
base = base[:i]
|
||||
base := baseCall(call)
|
||||
if len(base) < 3 {
|
||||
return "callsign = ?", []any{call}
|
||||
}
|
||||
return "(callsign = ? OR callsign LIKE ?)", []any{base, base + "/%"}
|
||||
return "(callsign = ? OR callsign LIKE ? OR callsign LIKE ? OR callsign LIKE ?)",
|
||||
[]any{
|
||||
base, // OE8NDR
|
||||
base + "/%", // OE8NDR/P
|
||||
"%/" + base, // ZA/OE8NDR
|
||||
"%/" + base + "/%", // ZA/OE8NDR/P
|
||||
}
|
||||
}
|
||||
|
||||
// WorkedBefore returns aggregated history at both callsign and DXCC level.
|
||||
@@ -3025,6 +3082,7 @@ func scanQSO(s scanner) (QSO, error) {
|
||||
myARRLSect, myVUCCGrids sql.NullString
|
||||
extrasJSON sql.NullString
|
||||
awardRefs sql.NullString
|
||||
syncUID sql.NullString
|
||||
createdStr, updatedStr string
|
||||
)
|
||||
if err := s.Scan(
|
||||
@@ -3052,7 +3110,7 @@ func scanQSO(s scanner) (QSO, error) {
|
||||
&skcc, &fists, &tenTen, &contactedOp, &eqCall, &pfx, &myName, &class,
|
||||
&darcDOK, &myDarcDOK, ®ion, &silentKey, &swl, &qsoComplete, &qsoRandom,
|
||||
&creditGranted, &creditSubmitted, &myARRLSect, &myVUCCGrids,
|
||||
&extrasJSON, &awardRefs, &createdStr, &updatedStr,
|
||||
&extrasJSON, &awardRefs, &syncUID, &createdStr, &updatedStr,
|
||||
); err != nil {
|
||||
return QSO{}, fmt.Errorf("scan qso: %w", err)
|
||||
}
|
||||
@@ -3252,6 +3310,7 @@ func scanQSO(s scanner) (QSO, error) {
|
||||
q.MyVUCCGrids = myVUCCGrids.String
|
||||
q.Extras = decodeExtras(extrasJSON.String)
|
||||
q.AwardRefs = awardRefs.String
|
||||
q.SyncUID = syncUID.String
|
||||
return q, nil
|
||||
}
|
||||
|
||||
@@ -3362,3 +3421,67 @@ func (r *Repo) OrderedIDs(ctx context.Context) ([]int64, time.Time, error) {
|
||||
// Parsed once, for the newest row only — the ordering came from SQL.
|
||||
return out, parseTimeLoose(lastDate), nil
|
||||
}
|
||||
|
||||
// --- Folder synchronisation identity -----------------------------------------
|
||||
|
||||
// SetSyncUID stamps a contact's sync identity. Targeted UPDATE: it must never
|
||||
// go through the normal write path, or an ordinary edit would clobber it.
|
||||
func (r *Repo) SetSyncUID(ctx context.Context, id int64, uid string) error {
|
||||
_, err := r.db.ExecContext(ctx, `UPDATE qso SET sync_uid = ? WHERE id = ?`, uid, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// IDBySyncUID resolves an incoming change to a local row. Indexed, so this is
|
||||
// the one lookup a sync performs per record and it stays a key hit rather than
|
||||
// a scan — which is the whole reason sync_uid is a column and not a JSON key.
|
||||
func (r *Repo) IDBySyncUID(ctx context.Context, uid string) (int64, bool, error) {
|
||||
if uid == "" {
|
||||
return 0, false, nil
|
||||
}
|
||||
var id int64
|
||||
err := r.db.QueryRowContext(ctx, `SELECT id FROM qso WHERE sync_uid = ? LIMIT 1`, uid).Scan(&id)
|
||||
if err == sql.ErrNoRows {
|
||||
return 0, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
return id, true, nil
|
||||
}
|
||||
|
||||
// IDByDedupeKey finds the local row a change refers to when its identity is
|
||||
// unknown here.
|
||||
//
|
||||
// The change log starts EMPTY, so contacts logged before synchronisation was
|
||||
// switched on are never exchanged — nothing has to be copied across, and
|
||||
// identities are stamped lazily on the contacts that are actually touched.
|
||||
//
|
||||
// One case still needs this. Two machines can already hold the SAME old contact
|
||||
// — the second was seeded by copying the database or importing an ADIF — with
|
||||
// different row ids and no identity on either. The day one of them edits that
|
||||
// contact, it stamps an identity and sends a change naming it; the other has
|
||||
// never seen that identity and would insert a duplicate. Matching on the
|
||||
// contact itself (callsign + minute + band + mode, the importer's own dedupe
|
||||
// key) recognises it instead.
|
||||
//
|
||||
// Looked up only when an identity is unknown, which is rare, and it goes
|
||||
// through idx_qso_callsign rather than scanning — a full map of every contact,
|
||||
// rebuilt each pass, was the wrong shape for something this occasional.
|
||||
func (r *Repo) IDByDedupeKey(ctx context.Context, callsign, qsoDateMinute, band, mode string) (id int64, uid string, found bool, err error) {
|
||||
if callsign == "" || qsoDateMinute == "" {
|
||||
return 0, "", false, nil
|
||||
}
|
||||
var u sql.NullString
|
||||
err = r.db.QueryRowContext(ctx, `
|
||||
SELECT id, sync_uid FROM qso
|
||||
WHERE callsign = ? AND substr(qso_date, 1, 16) = ? AND band = ? AND mode = ?
|
||||
LIMIT 1`,
|
||||
strings.ToUpper(callsign), qsoDateMinute, band, mode).Scan(&id, &u)
|
||||
if err == sql.ErrNoRows {
|
||||
return 0, "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, "", false, err
|
||||
}
|
||||
return id, u.String, true, nil
|
||||
}
|
||||
|
||||
@@ -70,12 +70,12 @@ func TestStatsNoNilSlices(t *testing.T) {
|
||||
}
|
||||
|
||||
// Contest metrics over a window. The two traps:
|
||||
// 1. "Best hour" must be the best ROLLING 60 minutes, not the best clock hour —
|
||||
// a run straddling 13:45–14:45 is invisible to clock-hour bucketing, and the
|
||||
// rolling figure is the one contesters quote.
|
||||
// 2. Both rates must be reported: QSOs ÷ whole window (honest, breaks included)
|
||||
// AND QSOs ÷ hours actually operated. Quoting only the latter is how an
|
||||
// 8-hour effort gets sold as a 48-hour score.
|
||||
// 1. "Best hour" must be the best ROLLING 60 minutes, not the best clock hour —
|
||||
// a run straddling 13:45–14:45 is invisible to clock-hour bucketing, and the
|
||||
// rolling figure is the one contesters quote.
|
||||
// 2. Both rates must be reported: QSOs ÷ whole window (honest, breaks included)
|
||||
// AND QSOs ÷ hours actually operated. Quoting only the latter is how an
|
||||
// 8-hour effort gets sold as a 48-hour score.
|
||||
func TestContestPeriodMetrics(t *testing.T) {
|
||||
base := time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC)
|
||||
at := func(min int) time.Time { return base.Add(time.Duration(min) * time.Minute) }
|
||||
@@ -90,8 +90,8 @@ func TestContestPeriodMetrics(t *testing.T) {
|
||||
times = append(times, entry{t: at(240 + i*5), op: "F5XYZ"}) // 16:00 …
|
||||
}
|
||||
|
||||
from := base // 12:00
|
||||
to := base.Add(6 * time.Hour) // 18:00 → a 6-hour window
|
||||
from := base // 12:00
|
||||
to := base.Add(6 * time.Hour) // 18:00 → a 6-hour window
|
||||
var s Stats
|
||||
s.periodMetrics(times, from, to, time.Time{}, time.Time{})
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package qso
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hamlog/internal/db"
|
||||
)
|
||||
|
||||
// openRepo gives a migrated, empty logbook on disk.
|
||||
func openRepo(t *testing.T) *Repo {
|
||||
t.Helper()
|
||||
conn, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { conn.Close() })
|
||||
return NewRepo(conn)
|
||||
}
|
||||
|
||||
func addQSO(t *testing.T, r *Repo, call string, when time.Time) int64 {
|
||||
t.Helper()
|
||||
id, err := r.Add(context.Background(), QSO{
|
||||
Callsign: call, QSODate: when, Band: "20m", Mode: "CW",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("insert %s: %v", call, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// A QSO written and read back must come out whole. selectCols and scanQSO are
|
||||
// two hand-maintained lists that must line up column for column, and adding
|
||||
// sync_uid touched both — a drift there fails every read at runtime, which no
|
||||
// compiler catches.
|
||||
func TestSyncUIDRoundTrips(t *testing.T) {
|
||||
r := openRepo(t)
|
||||
ctx := context.Background()
|
||||
id := addQSO(t, r, "M0ABC", time.Date(2026, 8, 16, 14, 32, 0, 0, time.UTC))
|
||||
|
||||
got, err := r.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if got.Callsign != "M0ABC" {
|
||||
t.Fatalf("read back %+v", got)
|
||||
}
|
||||
if got.SyncUID != "" {
|
||||
t.Errorf("a fresh QSO has identity %q — it should have none until sync is switched on", got.SyncUID)
|
||||
}
|
||||
|
||||
if err := r.SetSyncUID(ctx, id, "abc123"); err != nil {
|
||||
t.Fatalf("SetSyncUID: %v", err)
|
||||
}
|
||||
got, _ = r.GetByID(ctx, id)
|
||||
if got.SyncUID != "abc123" {
|
||||
t.Errorf("SyncUID = %q after stamping", got.SyncUID)
|
||||
}
|
||||
}
|
||||
|
||||
// An ordinary edit must NEVER clobber the identity. sync_uid is deliberately
|
||||
// outside columnList for this reason: another machine that has already seen the
|
||||
// contact addresses it by that id, and losing it makes the same QSO arrive
|
||||
// again as a new one.
|
||||
func TestAnEditDoesNotClobberTheIdentity(t *testing.T) {
|
||||
r := openRepo(t)
|
||||
ctx := context.Background()
|
||||
id := addQSO(t, r, "M0ABC", time.Date(2026, 8, 16, 14, 32, 0, 0, time.UTC))
|
||||
if err := r.SetSyncUID(ctx, id, "keepme"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
q, _ := r.GetByID(ctx, id)
|
||||
q.Name = "Edited"
|
||||
q.SyncUID = "" // exactly what a caller that knows nothing about sync sends
|
||||
if err := r.Update(ctx, q); err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
|
||||
got, _ := r.GetByID(ctx, id)
|
||||
if got.Name != "Edited" {
|
||||
t.Errorf("the edit did not take: %+v", got)
|
||||
}
|
||||
if got.SyncUID != "keepme" {
|
||||
t.Errorf("SyncUID = %q — an edit wiped the sync identity", got.SyncUID)
|
||||
}
|
||||
}
|
||||
|
||||
// The lookup an incoming change performs, once per record.
|
||||
func TestIDBySyncUID(t *testing.T) {
|
||||
r := openRepo(t)
|
||||
ctx := context.Background()
|
||||
id := addQSO(t, r, "M0ABC", time.Date(2026, 8, 16, 14, 32, 0, 0, time.UTC))
|
||||
_ = r.SetSyncUID(ctx, id, "u-1")
|
||||
|
||||
got, ok, err := r.IDBySyncUID(ctx, "u-1")
|
||||
if err != nil || !ok || got != id {
|
||||
t.Fatalf("IDBySyncUID = (%d,%v,%v), want (%d,true,nil)", got, ok, err, id)
|
||||
}
|
||||
if _, ok, _ := r.IDBySyncUID(ctx, "nope"); ok {
|
||||
t.Error("an unknown identity was resolved")
|
||||
}
|
||||
// An empty id must never match the rows that have none.
|
||||
if _, ok, _ := r.IDBySyncUID(ctx, ""); ok {
|
||||
t.Error("the empty identity matched a row — every un-stamped QSO would be that row")
|
||||
}
|
||||
}
|
||||
|
||||
// Two machines can already hold the SAME old contact — the second was seeded by
|
||||
// copying the database or importing an ADIF — with different row ids and no
|
||||
// identity on either. The day one of them edits it, it stamps an identity and
|
||||
// sends a change naming it; the other has never seen that identity and would
|
||||
// insert a duplicate. Matching on the contact itself recognises it instead.
|
||||
func TestIDByDedupeKeyRecognisesTheSameContact(t *testing.T) {
|
||||
r := openRepo(t)
|
||||
ctx := context.Background()
|
||||
when := time.Date(2026, 8, 16, 14, 32, 0, 0, time.UTC)
|
||||
id := addQSO(t, r, "M0ABC", when)
|
||||
|
||||
minute := when.UTC().Format("2006-01-02T15:04")
|
||||
gotID, uid, found, err := r.IDByDedupeKey(ctx, "M0ABC", minute, "20m", "CW")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("IDByDedupeKey = (%d,%q,%v,%v), want it found", gotID, uid, found, err)
|
||||
}
|
||||
if gotID != id {
|
||||
t.Errorf("resolved to id %d, want %d", gotID, id)
|
||||
}
|
||||
if uid != "" {
|
||||
t.Errorf("uid = %q, want empty until something stamps it", uid)
|
||||
}
|
||||
|
||||
// A contact this machine does not have must NOT match something else.
|
||||
if _, _, found, _ := r.IDByDedupeKey(ctx, "M0ABC", minute, "40m", "CW"); found {
|
||||
t.Error("a different band matched — the key must be all four parts")
|
||||
}
|
||||
if _, _, found, _ := r.IDByDedupeKey(ctx, "M0XYZ", minute, "20m", "CW"); found {
|
||||
t.Error("a different callsign matched")
|
||||
}
|
||||
// An empty key must never match anything.
|
||||
if _, _, found, _ := r.IDByDedupeKey(ctx, "", "", "", ""); found {
|
||||
t.Error("an empty key matched a row")
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,8 @@ func NewDenkovi(serial string, count int) Device {
|
||||
return denkoviStub{count: count}
|
||||
}
|
||||
|
||||
func (s denkoviStub) Count() int { return s.count }
|
||||
func (denkoviStub) Close() error { return nil }
|
||||
func (s denkoviStub) Count() int { return s.count }
|
||||
func (denkoviStub) Close() error { return nil }
|
||||
func (denkoviStub) Status(context.Context) ([]bool, error) {
|
||||
return nil, fmt.Errorf("Denkovi USB relay board is only supported on Windows")
|
||||
}
|
||||
|
||||
+127
-24
@@ -3,6 +3,8 @@ package relaydev
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -20,13 +22,28 @@ import (
|
||||
//
|
||||
// - one URL pair with {relay} in it, used for every relay:
|
||||
// http://192.168.1.9/relay?n={relay}&state=on
|
||||
// - or one pair per relay, when the box has no pattern to speak of:
|
||||
// - one pair per relay, when the box has no pattern to speak of:
|
||||
// relay 1 → http://192.168.1.9/FF0101 , relay 2 → .../FF0201
|
||||
//
|
||||
// The second is the reason this driver exists. A hand-made switch often has
|
||||
// URLs with nothing in common between channels, and a template with {relay}
|
||||
// cannot express that.
|
||||
//
|
||||
// TWO SUBSTITUTIONS are available in either form:
|
||||
//
|
||||
// {relay} the relay number, 1-based. {relay-1} for a board that counts its
|
||||
// channels from zero — otherwise the whole pattern has to be given
|
||||
// up for eight hand-typed URLs over one missing offset.
|
||||
// {value} that relay's LABEL, the name given to it in Relay labels. A switch
|
||||
// addressed by antenna name rather than by channel number
|
||||
// (…/relay?on=Ant1) is then one pattern instead of eight URLs, and
|
||||
// renaming the antenna re-addresses it — the name the operator reads
|
||||
// on the button and the name on the wire cannot drift apart because
|
||||
// they are the same string.
|
||||
//
|
||||
// The label is percent-encoded, so a name with a space or an accent goes out as
|
||||
// a valid URL rather than a request the board rejects without saying why.
|
||||
//
|
||||
// STATE IS REMEMBERED, NOT READ. Most of these boxes have no status endpoint,
|
||||
// or answer with a web page nobody can parse reliably. Status therefore returns
|
||||
// what we last commanded — see the method for what that costs.
|
||||
@@ -35,9 +52,13 @@ type httpGen struct {
|
||||
offURLs []string
|
||||
onPat string // pattern with {relay}, used when the per-relay URL is empty
|
||||
offPat string
|
||||
labels []string // index 0 = relay 1; what {value} resolves to
|
||||
user string
|
||||
pass string
|
||||
count int
|
||||
// insecure accepts a certificate nothing can verify — the self-signed one a
|
||||
// relay board on the LAN presents. Per board, and the operator's choice.
|
||||
insecure bool
|
||||
|
||||
mu sync.Mutex
|
||||
state []bool
|
||||
@@ -45,8 +66,8 @@ type httpGen struct {
|
||||
|
||||
// NewHTTPGeneric builds the driver. onURLs/offURLs are per relay (index 0 =
|
||||
// relay 1) and may be short or hold empty entries; onPat/offPat are the
|
||||
// fallback patterns.
|
||||
func NewHTTPGeneric(onURLs, offURLs []string, onPat, offPat, user, pass string, count int) Device {
|
||||
// fallback patterns; labels are the relay names {value} substitutes.
|
||||
func NewHTTPGeneric(onURLs, offURLs []string, onPat, offPat, user, pass string, count int, labels []string, insecure bool) Device {
|
||||
if count <= 0 {
|
||||
count = len(onURLs)
|
||||
}
|
||||
@@ -55,8 +76,8 @@ func NewHTTPGeneric(onURLs, offURLs []string, onPat, offPat, user, pass string,
|
||||
}
|
||||
return &httpGen{
|
||||
onURLs: onURLs, offURLs: offURLs,
|
||||
onPat: onPat, offPat: offPat,
|
||||
user: user, pass: pass, count: count,
|
||||
onPat: onPat, offPat: offPat, labels: labels,
|
||||
user: user, pass: pass, count: count, insecure: insecure,
|
||||
state: make([]bool, count),
|
||||
}
|
||||
}
|
||||
@@ -64,39 +85,121 @@ func NewHTTPGeneric(onURLs, offURLs []string, onPat, offPat, user, pass string,
|
||||
func (h *httpGen) Count() int { return h.count }
|
||||
func (h *httpGen) Close() error { return nil } // stateless HTTP, nothing to release
|
||||
|
||||
// urlFor picks the per-relay URL, falling back to the pattern.
|
||||
func (h *httpGen) urlFor(relay int, on bool) string {
|
||||
list, pat := h.offURLs, h.offPat
|
||||
// patFor returns the pattern for a direction, trimmed.
|
||||
func (h *httpGen) patFor(on bool) string {
|
||||
if on {
|
||||
list, pat = h.onURLs, h.onPat
|
||||
return strings.TrimSpace(h.onPat)
|
||||
}
|
||||
return strings.TrimSpace(h.offPat)
|
||||
}
|
||||
|
||||
// entryFor returns what was typed in the per-relay box for a direction.
|
||||
func (h *httpGen) entryFor(relay int, on bool) string {
|
||||
list := h.offURLs
|
||||
if on {
|
||||
list = h.onURLs
|
||||
}
|
||||
if i := relay - 1; i >= 0 && i < len(list) {
|
||||
if u := strings.TrimSpace(list[i]); u != "" {
|
||||
return u
|
||||
}
|
||||
return strings.TrimSpace(list[i])
|
||||
}
|
||||
if pat = strings.TrimSpace(pat); pat == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// labelFor returns the relay's name, as typed in Relay labels.
|
||||
func (h *httpGen) labelFor(relay int) string {
|
||||
if i := relay - 1; i >= 0 && i < len(h.labels) {
|
||||
return strings.TrimSpace(h.labels[i])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// urlFor builds the request for one relay in one direction: the per-relay URL
|
||||
// if there is one, the pattern otherwise, with both substitutions applied.
|
||||
func (h *httpGen) urlFor(relay int, on bool) string {
|
||||
u := h.entryFor(relay, on)
|
||||
if u == "" {
|
||||
u = h.patFor(on)
|
||||
}
|
||||
if u == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.ReplaceAll(pat, "{relay}", strconv.Itoa(relay))
|
||||
return expand(u, relay, h.labelFor(relay))
|
||||
}
|
||||
|
||||
// escapeValue percent-encodes a relay label for use anywhere in a URL.
|
||||
//
|
||||
// url.QueryEscape alone is wrong: it writes a space as "+", which is a space
|
||||
// only in a query string and a literal plus sign in a path. Encoding it as %20
|
||||
// instead is correct in both, and {value} may land in either.
|
||||
func escapeValue(s string) string {
|
||||
return strings.ReplaceAll(url.QueryEscape(s), "+", "%20")
|
||||
}
|
||||
|
||||
// withScheme supplies http:// when none was typed, and leaves https:// alone.
|
||||
//
|
||||
// The same rule the named boards get from relayBase, and it has to be here too:
|
||||
// this driver takes whole URLs rather than a host, and a line typed as
|
||||
// "192.168.1.9/Set0/1" would otherwise fail with "unsupported protocol scheme"
|
||||
// — an error about a scheme, for a field where nobody knew one was expected.
|
||||
// An https:// board (a reverse proxy fronting the shack, most often) is passed
|
||||
// through untouched and needs no other handling: it is the same HTTP client.
|
||||
func withScheme(u string) string {
|
||||
if u == "" {
|
||||
return ""
|
||||
}
|
||||
if l := strings.ToLower(u); strings.HasPrefix(l, "http://") || strings.HasPrefix(l, "https://") {
|
||||
return u
|
||||
}
|
||||
return "http://" + u
|
||||
}
|
||||
|
||||
// relayToken matches {relay} and its offset forms, {relay-1} / {relay+2}.
|
||||
var relayToken = regexp.MustCompile(`\{relay([+-]\d+)?\}`)
|
||||
|
||||
// expand substitutes {value} with the relay's label and {relay} with its
|
||||
// number, honouring an offset. A board that numbers its channels from zero is
|
||||
// written {relay-1}; without that the whole pattern has to be abandoned for
|
||||
// four hand-typed URLs.
|
||||
func expand(s string, relay int, label string) string {
|
||||
s = strings.ReplaceAll(s, "{value}", escapeValue(label))
|
||||
return relayToken.ReplaceAllStringFunc(s, func(m string) string {
|
||||
n := relay
|
||||
if i := strings.IndexAny(m, "+-"); i >= 0 {
|
||||
if off, err := strconv.Atoi(m[i : len(m)-1]); err == nil {
|
||||
n += off
|
||||
}
|
||||
}
|
||||
return strconv.Itoa(n)
|
||||
})
|
||||
}
|
||||
|
||||
func (h *httpGen) Set(ctx context.Context, relay int, on bool) error {
|
||||
if relay < 1 || relay > h.count {
|
||||
return fmt.Errorf("relay %d out of range 1..%d", relay, h.count)
|
||||
}
|
||||
u := h.urlFor(relay, on)
|
||||
if u == "" {
|
||||
// Naming the direction matters: an operator who filled the ON URLs and
|
||||
// left OFF empty gets a switch that latches, and "no URL configured"
|
||||
// alone would not say which half is missing.
|
||||
dir := "OFF"
|
||||
if on {
|
||||
dir = "ON"
|
||||
}
|
||||
// Naming the direction matters: an operator who filled the ON URLs and left
|
||||
// OFF empty gets a switch that latches, and "no URL configured" alone would
|
||||
// not say which half is missing.
|
||||
dir := "OFF"
|
||||
if on {
|
||||
dir = "ON"
|
||||
}
|
||||
tmpl := h.entryFor(relay, on)
|
||||
if tmpl == "" {
|
||||
tmpl = h.patFor(on)
|
||||
}
|
||||
if tmpl == "" {
|
||||
return fmt.Errorf("no %s URL configured for relay %d", dir, relay)
|
||||
}
|
||||
if _, err := get(ctx, u, h.user, h.pass); err != nil {
|
||||
// {value} with no label would send "?on=" — an empty parameter to an antenna
|
||||
// switch, which most boards answer with a cheerful 200 and no movement. Say
|
||||
// what is missing instead of firing it.
|
||||
if strings.Contains(tmpl, "{value}") && h.labelFor(relay) == "" {
|
||||
return fmt.Errorf("the %s URL for relay %d uses {value}, but relay %d has no label to put there", dir, relay, relay)
|
||||
}
|
||||
u := h.urlFor(relay, on)
|
||||
u = withScheme(u)
|
||||
if _, err := get(ctx, u, h.user, h.pass, h.insecure); err != nil {
|
||||
return err
|
||||
}
|
||||
h.mu.Lock()
|
||||
|
||||
@@ -22,7 +22,7 @@ func TestHTTPGenericPattern(t *testing.T) {
|
||||
|
||||
d := NewHTTPGeneric(nil, nil,
|
||||
srv.URL+"/relay?n={relay}&state=on",
|
||||
srv.URL+"/relay?n={relay}&state=off", "", "", 4)
|
||||
srv.URL+"/relay?n={relay}&state=off", "", "", 4, nil, false)
|
||||
if err := d.Set(context.Background(), 2, true); err != nil {
|
||||
t.Fatalf("Set on: %v", err)
|
||||
}
|
||||
@@ -53,7 +53,7 @@ func TestHTTPGenericPerRelayURLsWinOverThePattern(t *testing.T) {
|
||||
d := NewHTTPGeneric(
|
||||
[]string{srv.URL + "/FF0101", "", srv.URL + "/weird/on"},
|
||||
[]string{srv.URL + "/FF0100", "", ""},
|
||||
srv.URL+"/pattern/on/{relay}", srv.URL+"/pattern/off/{relay}", "", "", 3)
|
||||
srv.URL+"/pattern/on/{relay}", srv.URL+"/pattern/off/{relay}", "", "", 3, nil, false)
|
||||
|
||||
_ = d.Set(context.Background(), 1, true) // its own URL
|
||||
_ = d.Set(context.Background(), 2, true) // empty → falls back to the pattern
|
||||
@@ -66,10 +66,90 @@ func TestHTTPGenericPerRelayURLsWinOverThePattern(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// {value} is the relay's LABEL: a switch addressed by antenna name rather than
|
||||
// by channel number is one pattern instead of eight URLs.
|
||||
func TestHTTPGenericValueIsTheRelayLabel(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var got []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
got = append(got, r.URL.String())
|
||||
mu.Unlock()
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
d := NewHTTPGeneric(
|
||||
[]string{srv.URL + "/relay?on={value}"}, // per-relay URL
|
||||
nil,
|
||||
"", srv.URL+"/relay?off={value}", // and the pattern, for the other direction
|
||||
"", "", 3, []string{"Ant1", "Beam 20m", ""}, false)
|
||||
_ = d.Set(context.Background(), 1, true)
|
||||
_ = d.Set(context.Background(), 2, false)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
// The space in "Beam 20m" must go out as %20 — a "+" would be a literal plus
|
||||
// in a path, and this substitution can land in either half of a URL.
|
||||
want := []string{"/relay?on=Ant1", "/relay?off=Beam%2020m"}
|
||||
if strings.Join(got, " ") != strings.Join(want, " ") {
|
||||
t.Errorf("requested %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// {relay-1} for a board whose channels are numbered from zero.
|
||||
func TestHTTPGenericRelayOffset(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var got []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
got = append(got, r.URL.Path)
|
||||
mu.Unlock()
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
d := NewHTTPGeneric(nil, nil,
|
||||
srv.URL+"/set0/{relay-1}/1", srv.URL+"/set0/{relay-1}/0", "", "", 4, nil, false)
|
||||
_ = d.Set(context.Background(), 1, true)
|
||||
_ = d.Set(context.Background(), 4, false)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
want := []string{"/set0/0/1", "/set0/3/0"}
|
||||
if strings.Join(got, " ") != strings.Join(want, " ") {
|
||||
t.Errorf("requested %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A URL that uses {value} on an unlabelled relay would go out as "?on=" — an
|
||||
// empty parameter, which most boards answer with a cheerful 200 and no
|
||||
// movement. It must be refused, and the message must say the label is what is
|
||||
// missing.
|
||||
func TestHTTPGenericRefusesValueWithoutALabel(t *testing.T) {
|
||||
d := NewHTTPGeneric(nil, nil, "http://x/relay?on={value}", "", "", "", 2, []string{"", ""}, false)
|
||||
err := d.Set(context.Background(), 1, true)
|
||||
if err == nil || !strings.Contains(err.Error(), "label") {
|
||||
t.Errorf("err = %v, want it to name the missing label", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A URL typed without a scheme must still be sent — the named boards take a
|
||||
// bare host and add http:// themselves, and this one has to behave the same.
|
||||
// https:// is left exactly as typed.
|
||||
func TestHTTPGenericSuppliesTheScheme(t *testing.T) {
|
||||
for _, c := range []struct{ in, want string }{
|
||||
{"192.168.1.9/Set0/1", "http://192.168.1.9/Set0/1"},
|
||||
{"http://192.168.1.9/x", "http://192.168.1.9/x"},
|
||||
{"https://relay.example.com/x", "https://relay.example.com/x"},
|
||||
{"HTTPS://relay.example.com/x", "HTTPS://relay.example.com/x"},
|
||||
} {
|
||||
if got := withScheme(c.in); got != c.want {
|
||||
t.Errorf("withScheme(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A switch with the ON URLs filled and OFF left empty latches. The error has to
|
||||
// name the direction, or the operator cannot tell which half is missing.
|
||||
func TestHTTPGenericNamesTheMissingDirection(t *testing.T) {
|
||||
d := NewHTTPGeneric([]string{"http://x/on"}, nil, "", "", "", "", 1)
|
||||
d := NewHTTPGeneric([]string{"http://x/on"}, nil, "", "", "", "", 1, nil, false)
|
||||
err := d.Set(context.Background(), 1, false)
|
||||
if err == nil || !strings.Contains(err.Error(), "OFF") {
|
||||
t.Errorf("err = %v, want it to name the OFF direction", err)
|
||||
@@ -80,7 +160,7 @@ func TestHTTPGenericNamesTheMissingDirection(t *testing.T) {
|
||||
func TestHTTPGenericRemembersWhatItCommanded(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
defer srv.Close()
|
||||
d := NewHTTPGeneric(nil, nil, srv.URL+"/on/{relay}", srv.URL+"/off/{relay}", "", "", 3)
|
||||
d := NewHTTPGeneric(nil, nil, srv.URL+"/on/{relay}", srv.URL+"/off/{relay}", "", "", 3, nil, false)
|
||||
_ = d.Set(context.Background(), 2, true)
|
||||
st, err := d.Status(context.Background())
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package relaydev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A relay board on the LAN signs its own certificate — there is no authority
|
||||
// anywhere that could have signed it. httptest.NewTLSServer presents exactly
|
||||
// that: a certificate from an unknown issuer, which is what the hardware does.
|
||||
func selfSignedRelay(t *testing.T) (*httptest.Server, func() []string) {
|
||||
t.Helper()
|
||||
var mu sync.Mutex
|
||||
var got []string
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
got = append(got, r.URL.Path)
|
||||
mu.Unlock()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, func() []string {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return append([]string(nil), got...)
|
||||
}
|
||||
}
|
||||
|
||||
// With the box ticked, the board answers.
|
||||
func TestHTTPSRelayWithASelfSignedCertificate(t *testing.T) {
|
||||
srv, seen := selfSignedRelay(t)
|
||||
d := NewHTTPGeneric(nil, nil, srv.URL+"/on/{relay}", srv.URL+"/off/{relay}", "", "", 2, nil, true)
|
||||
if err := d.Set(context.Background(), 1, true); err != nil {
|
||||
t.Fatalf("Set over HTTPS: %v", err)
|
||||
}
|
||||
if paths := seen(); len(paths) != 1 || paths[0] != "/on/1" {
|
||||
t.Errorf("the board was asked for %v, want /on/1", paths)
|
||||
}
|
||||
}
|
||||
|
||||
// Without it, the request is refused — and the refusal has to name the box.
|
||||
//
|
||||
// Go's own message, "x509: certificate signed by unknown authority", is
|
||||
// accurate and tells an operator nothing about what to do next. This is the
|
||||
// difference between a dead end and an instruction, and it is the whole reason
|
||||
// the default can safely stay OFF.
|
||||
func TestARefusedCertificateNamesTheSetting(t *testing.T) {
|
||||
srv, seen := selfSignedRelay(t)
|
||||
d := NewHTTPGeneric(nil, nil, srv.URL+"/on/{relay}", srv.URL+"/off/{relay}", "", "", 2, nil, false)
|
||||
err := d.Set(context.Background(), 1, true)
|
||||
if err == nil {
|
||||
t.Fatal("an unverifiable certificate was accepted with the box unticked")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "self-signed") {
|
||||
t.Errorf("the refusal reads %q — it does not say which setting to change", err)
|
||||
}
|
||||
if len(seen()) != 0 {
|
||||
t.Error("the request reached the board despite the certificate being refused")
|
||||
}
|
||||
}
|
||||
|
||||
// The box belongs to ONE board. An operator with a self-signed switch on the
|
||||
// LAN and a second board reached through a proper HTTPS proxy must keep real
|
||||
// verification on the second — that link crosses the internet, and it commands
|
||||
// an antenna.
|
||||
func TestAcceptingOneBoardsCertificateDoesNotAffectAnother(t *testing.T) {
|
||||
srv, _ := selfSignedRelay(t)
|
||||
lan := NewHTTPGeneric(nil, nil, srv.URL+"/on/{relay}", "", "", "", 1, nil, true)
|
||||
if err := lan.Set(context.Background(), 1, true); err != nil {
|
||||
t.Fatalf("the LAN board: %v", err)
|
||||
}
|
||||
strict := NewHTTPGeneric(nil, nil, srv.URL+"/on/{relay}", "", "", "", 1, nil, false)
|
||||
if err := strict.Set(context.Background(), 1, true); err == nil {
|
||||
t.Error("the second board accepted the certificate too — the setting is not per board")
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,10 @@ package relaydev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -28,8 +31,8 @@ import (
|
||||
|
||||
// Device is one relay board.
|
||||
type Device interface {
|
||||
Count() int // number of user-controllable relays
|
||||
Status(ctx context.Context) ([]bool, error) // state of each relay (index 0 = relay 1)
|
||||
Count() int // number of user-controllable relays
|
||||
Status(ctx context.Context) ([]bool, error) // state of each relay (index 0 = relay 1)
|
||||
Set(ctx context.Context, relay int, on bool) error // relay is 1-based
|
||||
// Close releases any OS handle the driver holds (serial port, FTDI handle).
|
||||
// Network boards hold nothing and no-op. MUST be called when a cached driver is
|
||||
@@ -40,8 +43,47 @@ type Device interface {
|
||||
|
||||
func httpClient() *http.Client { return &http.Client{Timeout: 5 * time.Second} }
|
||||
|
||||
// insecureClient talks to a board presenting a certificate nothing can verify.
|
||||
//
|
||||
// Which is nearly every board that offers HTTPS at all: a relay box on the LAN
|
||||
// signs its own certificate, and there is no authority anywhere that could have
|
||||
// signed it. Refusing that means refusing HTTPS on the hardware, which is not a
|
||||
// security decision, only an outcome.
|
||||
//
|
||||
// So it is offered, per board, and OFF by default — because the other HTTPS
|
||||
// case is real and opposite: a board reached from outside through a proxy with
|
||||
// a genuine certificate, where verification is the only thing standing between
|
||||
// an antenna switch and the internet. One box, on the board that needs it.
|
||||
//
|
||||
// Built once. A Transport per request would open a fresh TLS connection every
|
||||
// time and never reuse one.
|
||||
var insecureClient = &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // the operator ticked the box for this board
|
||||
},
|
||||
}
|
||||
|
||||
// certError says which box to tick when TLS is what failed.
|
||||
//
|
||||
// Go's own message — "x509: certificate signed by unknown authority" — is
|
||||
// accurate and tells an operator nothing about what to do next. Naming the
|
||||
// setting turns a dead end into an instruction.
|
||||
func certError(err error) error {
|
||||
var unknown x509.UnknownAuthorityError
|
||||
var host x509.HostnameError
|
||||
var verify *tls.CertificateVerificationError
|
||||
if errors.As(err, &unknown) || errors.As(err, &host) || errors.As(err, &verify) {
|
||||
return fmt.Errorf("%w — the board's HTTPS certificate cannot be verified; "+
|
||||
"tick \"Accept a self-signed certificate\" for this board if it is on your own network", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// get issues a GET with optional basic auth and returns the body on 2xx.
|
||||
func get(ctx context.Context, url, user, pass string) ([]byte, error) {
|
||||
//
|
||||
// insecure skips certificate verification, for a board that signs its own.
|
||||
func get(ctx context.Context, url, user, pass string, insecure bool) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -49,9 +91,13 @@ func get(ctx context.Context, url, user, pass string) ([]byte, error) {
|
||||
if user != "" || pass != "" {
|
||||
req.SetBasicAuth(user, pass)
|
||||
}
|
||||
resp, err := httpClient().Do(req)
|
||||
client := httpClient()
|
||||
if insecure {
|
||||
client = insecureClient
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, certError(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
@@ -87,8 +133,8 @@ type webswitch struct {
|
||||
// NewWebswitch builds a WebSwitch 1216H client (5 relays).
|
||||
func NewWebswitch(host string) Device { return &webswitch{host: host, count: 5} }
|
||||
|
||||
func (w *webswitch) Count() int { return w.count }
|
||||
func (w *webswitch) Close() error { return nil } // stateless HTTP, nothing to release
|
||||
func (w *webswitch) Count() int { return w.count }
|
||||
func (w *webswitch) Close() error { return nil } // stateless HTTP, nothing to release
|
||||
|
||||
func (w *webswitch) Set(ctx context.Context, relay int, on bool) error {
|
||||
if relay < 1 || relay > w.count {
|
||||
@@ -98,7 +144,7 @@ func (w *webswitch) Set(ctx context.Context, relay int, on bool) error {
|
||||
if on {
|
||||
action = "on"
|
||||
}
|
||||
_, err := get(ctx, fmt.Sprintf("%s/relaycontrol/%s/%d", relayBase(w.host), action, relay), "", "")
|
||||
_, err := get(ctx, fmt.Sprintf("%s/relaycontrol/%s/%d", relayBase(w.host), action, relay), "", "", false)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -109,7 +155,7 @@ func (w *webswitch) Status(ctx context.Context) ([]bool, error) {
|
||||
sel.WriteString(strconv.Itoa(i))
|
||||
sel.WriteByte('$')
|
||||
}
|
||||
body, err := get(ctx, fmt.Sprintf("%s/relaystate/get2/%s", relayBase(w.host), sel.String()), "", "")
|
||||
body, err := get(ctx, fmt.Sprintf("%s/relaystate/get2/%s", relayBase(w.host), sel.String()), "", "", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -156,7 +202,7 @@ func (k *kmtronic) Set(ctx context.Context, relay int, on bool) error {
|
||||
state = "01"
|
||||
}
|
||||
// FF<rr><ss>: e.g. FF0101 = relay 1 on, FF0800 = relay 8 off.
|
||||
_, err := get(ctx, fmt.Sprintf("%s/FF%02d%s", relayBase(k.host), relay, state), k.user, k.pass)
|
||||
_, err := get(ctx, fmt.Sprintf("%s/FF%02d%s", relayBase(k.host), relay, state), k.user, k.pass, false)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -170,7 +216,7 @@ type kmStatus struct {
|
||||
}
|
||||
|
||||
func (k *kmtronic) Status(ctx context.Context) ([]bool, error) {
|
||||
body, err := get(ctx, fmt.Sprintf("%s/status.xml", relayBase(k.host)), k.user, k.pass)
|
||||
body, err := get(ctx, fmt.Sprintf("%s/status.xml", relayBase(k.host)), k.user, k.pass, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// just to turn an antenna. Two towers with a controller each is the ordinary
|
||||
// case; each one is a separate serial port and a separate rotor in OpsLog.
|
||||
//
|
||||
// WIRE FORMAT
|
||||
// # WIRE FORMAT
|
||||
//
|
||||
// Every command is 13 bytes:
|
||||
//
|
||||
@@ -30,6 +30,13 @@
|
||||
//
|
||||
// az = H1×100 + H2×10 + H3 + H4/10 − 360
|
||||
//
|
||||
// ROT1PROG IS THREE DIGITS IN BOTH DIRECTIONS. Its reply carries three, and so
|
||||
// does its command — the controller reads the azimuth from offsets 1, 2 and 3,
|
||||
// with no resolution scaling (it is one pulse per degree). Sending it the
|
||||
// four-digit Rot2Prog form shifts every target by a decimal place: 90° goes out
|
||||
// as "0450" and is read as 045, which is −315°, so every command turns the
|
||||
// antenna nearly a full circle the wrong way. Found on a tower, not here.
|
||||
//
|
||||
// The 360 offset is what lets the controller report a rotator that has turned
|
||||
// past north in either direction, which is the point of a pulse-counting
|
||||
// rotator: −180…540 rather than 0…359.
|
||||
@@ -111,7 +118,22 @@ func BuildStop() []byte { return buildCmd(0, 0, 0, 0, cmdStop) }
|
||||
// Azimuth is offset by 360 before scaling, so a target of −10° and one of 350°
|
||||
// are different instructions: the first turns anticlockwise past north, the
|
||||
// second does not. Feeding a 0…359 heading in is therefore always safe.
|
||||
func BuildSet(az, el float64, resolution byte) []byte {
|
||||
//
|
||||
// ROT1PROG SENDS THREE DIGITS, NOT FOUR, and that is the whole reason this
|
||||
// takes a model. Its reply is three digits — a 5-byte frame — and its command
|
||||
// field matches: the controller reads the azimuth from offsets 1, 2 and 3.
|
||||
//
|
||||
// Sending the four-digit Rot2Prog form to one shifts every target by a decimal
|
||||
// place. "0450" for 90° was read as 045, i.e. 45 − 360 = −315°, so every
|
||||
// command became a near-full turn ANTICLOCKWISE whatever was asked for — 0°,
|
||||
// 90°, 180°, all of them. That is exactly how it was reported from a tower:
|
||||
// every heading wanted to go the wrong way round, and a "point to 0°" test that
|
||||
// did nothing useful.
|
||||
func BuildSet(az, el float64, resolution byte, model Model) []byte {
|
||||
if model == Rot1Prog {
|
||||
// No scaling: a Rot1Prog is one pulse per degree and reports resolution 1.
|
||||
return buildCmd3(int(360+az+0.5), int(360+el+0.5))
|
||||
}
|
||||
if resolution == 0 {
|
||||
resolution = 1
|
||||
}
|
||||
@@ -120,6 +142,26 @@ func BuildSet(az, el float64, resolution byte) []byte {
|
||||
return buildCmd(uaz, uel, resolution, resolution, cmdSet)
|
||||
}
|
||||
|
||||
// buildCmd3 frames a Rot1Prog target: three ASCII digits per axis at the same
|
||||
// offsets its replies use, the fourth digit position left as '0'.
|
||||
func buildCmd3(uaz, uel int) []byte {
|
||||
c := make([]byte, 13)
|
||||
c[0] = frameStart
|
||||
c[1] = '0' + byte(uaz/100%10)
|
||||
c[2] = '0' + byte(uaz/10%10)
|
||||
c[3] = '0' + byte(uaz%10)
|
||||
c[4] = '0'
|
||||
c[5] = 0x01
|
||||
c[6] = '0' + byte(uel/100%10)
|
||||
c[7] = '0' + byte(uel/10%10)
|
||||
c[8] = '0' + byte(uel%10)
|
||||
c[9] = '0'
|
||||
c[10] = 0x01
|
||||
c[11] = cmdSet
|
||||
c[12] = frameEnd
|
||||
return c
|
||||
}
|
||||
|
||||
func buildCmd(uaz, uel int, ph, pv byte, k byte) []byte {
|
||||
c := make([]byte, 13)
|
||||
c[0] = frameStart
|
||||
@@ -175,7 +217,7 @@ func (c *Client) GoTo(az int, el int) error {
|
||||
if el >= 0 && c.model == Rot2Prog {
|
||||
e = float64(el)
|
||||
}
|
||||
_, err := c.exchange(BuildSet(float64(az), e, res), 0)
|
||||
_, err := c.exchange(BuildSet(float64(az), e, res, c.model), 0)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -12,21 +12,67 @@ import (
|
||||
func TestSetFrameMatchesTheReference(t *testing.T) {
|
||||
// Hamlib: u_az = PH × (360 + az), then the four decimal digits as ASCII;
|
||||
// PH and PV raw; K = 0x2F.
|
||||
got := BuildSet(0, 0, 1) // 360 → "0360"
|
||||
got := BuildSet(0, 0, 1, Rot2Prog) // 360 → "0360"
|
||||
want := []byte{0x57, '0', '3', '6', '0', 0x01, '0', '3', '6', '0', 0x01, 0x2F, 0x20}
|
||||
assertBytes(t, "az 0 res 1", got, want)
|
||||
|
||||
// 90° at half-degree resolution: 2 × 450 = 900 → "0900".
|
||||
got = BuildSet(90, 0, 2)
|
||||
got = BuildSet(90, 0, 2, Rot2Prog)
|
||||
want = []byte{0x57, '0', '9', '0', '0', 0x02, '0', '7', '2', '0', 0x02, 0x2F, 0x20}
|
||||
assertBytes(t, "az 90 res 2", got, want)
|
||||
|
||||
// A quarter-degree controller, 359°: 4 × 719 = 2876.
|
||||
got = BuildSet(359, 0, 4)
|
||||
got = BuildSet(359, 0, 4, Rot2Prog)
|
||||
want = []byte{0x57, '2', '8', '7', '6', 0x04, '1', '4', '4', '0', 0x04, 0x2F, 0x20}
|
||||
assertBytes(t, "az 359 res 4", got, want)
|
||||
}
|
||||
|
||||
// A Rot1Prog reads its azimuth from offsets 1, 2 and 3 — three digits, the same
|
||||
// field its five-byte replies use.
|
||||
//
|
||||
// This is field evidence, not a reading of the reference: sending the
|
||||
// four-digit Rot2Prog form to a RAK/RAU made every command turn the antenna
|
||||
// nearly a full circle ANTICLOCKWISE. "0450" for 90° was read as 045, which is
|
||||
// 45 − 360 = −315°, and the same shift made 0°, 180° and every other target go
|
||||
// the wrong way round too. The three cases below are the ones that were tried
|
||||
// on the tower.
|
||||
func TestRot1ProgSetFrameIsThreeDigits(t *testing.T) {
|
||||
// 0° → 360 → "360". The "point to 0°" test that did nothing.
|
||||
assertBytes(t, "rot1prog az 0", BuildSet(0, 0, 1, Rot1Prog),
|
||||
[]byte{0x57, '3', '6', '0', '0', 0x01, '3', '6', '0', '0', 0x01, 0x2F, 0x20})
|
||||
|
||||
// 90° → 450 → "450". Sent as "0450" it read as 45 − 360 = −315°, which from
|
||||
// 45° is a full turn the wrong way.
|
||||
assertBytes(t, "rot1prog az 90", BuildSet(90, 0, 1, Rot1Prog),
|
||||
[]byte{0x57, '4', '5', '0', '0', 0x01, '3', '6', '0', '0', 0x01, 0x2F, 0x20})
|
||||
|
||||
// 359° → 719 → "719": three digits still, at the top of the range.
|
||||
assertBytes(t, "rot1prog az 359", BuildSet(359, 0, 1, Rot1Prog),
|
||||
[]byte{0x57, '7', '1', '9', '0', 0x01, '3', '6', '0', '0', 0x01, 0x2F, 0x20})
|
||||
}
|
||||
|
||||
// What a Rot1Prog is SENT and what it REPORTS have to be the same number, or
|
||||
// the antenna goes somewhere nobody asked for. Round-tripping every degree of
|
||||
// the circle through the command builder and the reply parser is the cheapest
|
||||
// way to say that, and it is the check that would have caught the four-digit
|
||||
// frame before it reached a tower.
|
||||
func TestRot1ProgCommandAndReplyAgree(t *testing.T) {
|
||||
for deg := 0; deg < 360; deg++ {
|
||||
cmd := BuildSet(float64(deg), 0, 1, Rot1Prog)
|
||||
// The controller reads three ASCII digits and answers with the same value
|
||||
// in raw bytes — the asymmetry this protocol is built on.
|
||||
reply := []byte{0x57, cmd[1] - '0', cmd[2] - '0', cmd[3] - '0', 0x20}
|
||||
az, _, _, err := ParseStatus(reply, Rot1Prog)
|
||||
if err != nil {
|
||||
t.Fatalf("%d°: %v", deg, err)
|
||||
}
|
||||
if int(az+0.5) != deg {
|
||||
t.Fatalf("commanded %d°, the controller would report %v° — a %v° error",
|
||||
deg, az, az-float64(deg))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Status and stop carry no position: every data byte is zero, only K differs.
|
||||
func TestStatusAndStopFrames(t *testing.T) {
|
||||
assertBytes(t, "status", BuildStatus(),
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package steppir
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// silentPort is a serial port that has stopped answering: every read times out.
|
||||
// On Windows that is reported as (0, nil) — a timeout is not an error on this
|
||||
// transport — which is precisely what io.ReadFull cannot survive.
|
||||
type silentPort struct{ reads int }
|
||||
|
||||
func (p *silentPort) Read(b []byte) (int, error) {
|
||||
p.reads++
|
||||
time.Sleep(5 * time.Millisecond) // stand in for the port's read timeout
|
||||
return 0, nil
|
||||
}
|
||||
func (p *silentPort) Write(b []byte) (int, error) { return len(b), nil }
|
||||
func (p *silentPort) Close() error { return nil }
|
||||
|
||||
// A controller that goes quiet must make the read FAIL, not hang. Hanging held
|
||||
// the io mutex, so the poll loop never reported a fault and every operator
|
||||
// command blocked behind it: the antenna stopped responding and the log had
|
||||
// nothing in it.
|
||||
func TestReadFrameGivesUpOnASilentController(t *testing.T) {
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- readFrame(&silentPort{}, make([]byte, 11), 100*time.Millisecond)
|
||||
}()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err == nil {
|
||||
t.Fatal("a silent controller was reported as a good frame")
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("readFrame never returned — the driver is wedged exactly as it was in the field")
|
||||
}
|
||||
}
|
||||
|
||||
// dribblePort delivers the frame a few bytes at a time, with empty reads in
|
||||
// between — a slow 4800-baud link, which must still assemble one frame.
|
||||
type dribblePort struct {
|
||||
data []byte
|
||||
step int
|
||||
idle int // empty reads before each chunk
|
||||
n int
|
||||
}
|
||||
|
||||
func (p *dribblePort) Read(b []byte) (int, error) {
|
||||
if p.n < p.idle {
|
||||
p.n++
|
||||
return 0, nil
|
||||
}
|
||||
p.n = 0
|
||||
if len(p.data) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
k := p.step
|
||||
if k > len(p.data) {
|
||||
k = len(p.data)
|
||||
}
|
||||
if k > len(b) {
|
||||
k = len(b)
|
||||
}
|
||||
copy(b, p.data[:k])
|
||||
p.data = p.data[k:]
|
||||
return k, nil
|
||||
}
|
||||
func (p *dribblePort) Write(b []byte) (int, error) { return len(b), nil }
|
||||
func (p *dribblePort) Close() error { return nil }
|
||||
|
||||
func TestReadFrameAssemblesASlowFrame(t *testing.T) {
|
||||
want := []byte{'@', 'A', 0x00, 0x20, 0x1E, 0xA8, 0x00, 0x05, 0x30, 0x37, 0x0D}
|
||||
p := &dribblePort{data: append([]byte(nil), want...), step: 3, idle: 2}
|
||||
buf := make([]byte, 11)
|
||||
if err := readFrame(p, buf, time.Second); err != nil {
|
||||
t.Fatalf("readFrame: %v", err)
|
||||
}
|
||||
for i := range want {
|
||||
if buf[i] != want[i] {
|
||||
t.Fatalf("read % X, want % X", buf, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A truncated frame is a failure, not a frame. The controller sending 5 bytes
|
||||
// and stopping used to spin forever on the missing 6.
|
||||
func TestReadFrameRejectsATruncatedFrame(t *testing.T) {
|
||||
p := &dribblePort{data: []byte{'@', 'A', 0x00, 0x20, 0x1E}, step: 5}
|
||||
err := readFrame(p, make([]byte, 11), 100*time.Millisecond)
|
||||
if err == nil {
|
||||
t.Fatal("a 5-byte frame was accepted as 11")
|
||||
}
|
||||
}
|
||||
|
||||
// A real error still comes straight back.
|
||||
func TestReadFrameReturnsPortErrors(t *testing.T) {
|
||||
want := errors.New("port closed")
|
||||
p := errPort{err: want}
|
||||
if err := readFrame(p, make([]byte, 11), time.Second); !errors.Is(err, want) {
|
||||
t.Fatalf("err = %v, want %v", err, want)
|
||||
}
|
||||
}
|
||||
|
||||
type errPort struct{ err error }
|
||||
|
||||
func (p errPort) Read([]byte) (int, error) { return 0, p.err }
|
||||
func (p errPort) Write(b []byte) (int, error) { return len(b), nil }
|
||||
func (p errPort) Close() error { return nil }
|
||||
|
||||
var _ io.ReadWriteCloser = errPort{}
|
||||
@@ -419,6 +419,45 @@ func drain(conn io.ReadWriteCloser) int {
|
||||
return total
|
||||
}
|
||||
|
||||
// frameTimeout bounds the wait for one 11-byte status reply. At 4800 baud the
|
||||
// frame itself takes ~23 ms; three seconds is a controller that is not going to
|
||||
// answer this query.
|
||||
const frameTimeout = 3 * time.Second
|
||||
|
||||
// readFrame reads exactly len(buf) bytes, or gives up.
|
||||
//
|
||||
// io.ReadFull CANNOT be used on a serial port, and using it here is what made an
|
||||
// antenna "stop responding after a while" with nothing whatsoever in the log.
|
||||
//
|
||||
// On Windows a serial read that times out returns (0, nil) — a timeout is not an
|
||||
// error on this transport. io.ReadFull loops while err == nil, so a controller
|
||||
// that goes quiet, or sends a truncated frame, spins it forever. It holds ioMu
|
||||
// the whole time, and that is the part the operator sees: the poll goroutine
|
||||
// never returns to report a fault, so the last status stays on screen and the
|
||||
// link still looks connected — while every command blocks on the same mutex.
|
||||
// The trace line used to sit AFTER that lock, so even the attempt went unlogged.
|
||||
// One dropped reply on a 4800-baud link wedged the driver until OpsLog restarted.
|
||||
//
|
||||
// Giving up returns an error, which the poll loop already knows how to handle:
|
||||
// it says so in the log and reconnects.
|
||||
func readFrame(conn io.ReadWriteCloser, buf []byte, d time.Duration) error {
|
||||
deadline := time.Now().Add(d)
|
||||
for n := 0; n < len(buf); {
|
||||
m, err := conn.Read(buf[n:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n += m
|
||||
if n >= len(buf) {
|
||||
return nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("timed out after %s with %d of %d bytes", d, n, len(buf))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) queryStatus() (*Status, error) {
|
||||
c.connMu.Lock()
|
||||
conn := c.conn
|
||||
@@ -438,7 +477,7 @@ func (c *Client) queryStatus() (*Status, error) {
|
||||
return nil, fmt.Errorf("write status cmd: %w", err)
|
||||
}
|
||||
buf := make([]byte, 11)
|
||||
if _, err := io.ReadFull(conn, buf); err != nil {
|
||||
if err := readFrame(conn, buf, frameTimeout); err != nil {
|
||||
return nil, fmt.Errorf("read status: %w", err)
|
||||
}
|
||||
// Reject anything that isn't a framed reply rather than decoding garbage into
|
||||
@@ -523,9 +562,14 @@ func (c *Client) writeCmd(pkt []byte) error {
|
||||
if conn == nil {
|
||||
return fmt.Errorf("steppir: not connected")
|
||||
}
|
||||
// Traced BEFORE taking the lock, not after. A command waits here for the poll
|
||||
// in flight, and when that wait was unbounded the log showed no sign the
|
||||
// operator had asked for anything at all — the one fact that would have named
|
||||
// the fault. The line now means "asked for"; a failure to write is reported
|
||||
// by the caller.
|
||||
log.Printf("steppir: → % X", pkt)
|
||||
c.ioMu.Lock()
|
||||
defer c.ioMu.Unlock()
|
||||
log.Printf("steppir: → % X", pkt)
|
||||
setDeadline(conn, 3*time.Second)
|
||||
if _, err := conn.Write(pkt); err != nil {
|
||||
c.closeConn()
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
// Package syncfolder keeps one operator's logbook in step across several PCs
|
||||
// through a folder they already have — Seafile, OneDrive, Dropbox, a NAS share,
|
||||
// a USB stick.
|
||||
//
|
||||
// WHY NOT THE DATABASE FILE ITSELF. Because it corrupts. SQLite relies on
|
||||
// advisory file locks that SMB and NFS implement partially or cache, so two
|
||||
// machines can both believe they hold the lock; and in WAL mode — which OpsLog
|
||||
// uses — the shared-memory index (-shm) has no meaning across machines at all.
|
||||
// A cloud folder is worse again: it replicates the file whole while it is open,
|
||||
// and .db / .db-wal / .db-shm drift apart, giving a database that opens
|
||||
// perfectly and is silently missing the last few hours.
|
||||
//
|
||||
// THE RULE THAT MAKES A SHARED FOLDER SAFE: one writer per file, append only.
|
||||
// Each machine writes ONLY its own <machine>.ndjson and never touches another's.
|
||||
// A sync tool that replicates whole files can therefore never merge two writers
|
||||
// into one file, because there are never two writers. This is the exact opposite
|
||||
// of putting the database there, and it is why it works.
|
||||
//
|
||||
// WHAT THIS IS NOT. Not live. Two operators logging the same contest second by
|
||||
// second want the shared MySQL logbook, which OpsLog already does; that is a
|
||||
// different need and it stays. This is for ONE operator with a shack PC, a
|
||||
// laptop and a portable rig — the case where a server running day and night to
|
||||
// serve forty QSOs a month is the wrong shape.
|
||||
//
|
||||
// It is the other half of internal/offlineq, whose own doc says it is
|
||||
// "deliberately NOT a sync engine: no mirror, no pull, no merge, no tombstones".
|
||||
// Those four are precisely what is here.
|
||||
package syncfolder
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DirName is the sub-folder created inside whatever the operator picked. A
|
||||
// dedicated folder, so pointing OpsLog at a documents directory by mistake does
|
||||
// not scatter files through it.
|
||||
const DirName = "opslog-sync"
|
||||
|
||||
// FormatVersion is stamped on every record. A future OpsLog that changes the
|
||||
// shape can then recognise — and skip — what it does not understand, instead of
|
||||
// misreading it. Records from the future are skipped, never guessed at.
|
||||
const FormatVersion = 1
|
||||
|
||||
// Op is what happened to a contact.
|
||||
type Op string
|
||||
|
||||
const (
|
||||
OpAdd Op = "add"
|
||||
OpUpdate Op = "update"
|
||||
// OpDelete is a TOMBSTONE, and it is the reason this is a change log rather
|
||||
// than a pile of ADIF. Without one, a QSO deleted on the laptop comes
|
||||
// straight back on the next sync from the shack PC, for ever.
|
||||
OpDelete Op = "delete"
|
||||
)
|
||||
|
||||
// Record is one line of a machine's file. NDJSON: one object per line, appended,
|
||||
// never rewritten — so a half-written line at the tail costs one record, not the
|
||||
// file, and a reader can resume from a byte offset.
|
||||
type Record struct {
|
||||
V int `json:"v"`
|
||||
Op Op `json:"op"`
|
||||
UID string `json:"uid"` // the contact's stable identity
|
||||
At time.Time `json:"at"` // when this CHANGE was made, UTC
|
||||
Seq uint64 `json:"seq"` // this machine's own counter, monotonic
|
||||
By string `json:"by"` // machine id that wrote it
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// NewUID mints a contact's identity.
|
||||
//
|
||||
// A stable id per contact is what lets an edit or a deletion be addressed at
|
||||
// all: "the QSO with M0ABC at 14:32" is a guess, and two machines can disagree
|
||||
// about which row that is. OpsLog already mints one for the offline outbox
|
||||
// (APP_OPSLOG_QUEUEID); this is the same idea, kept for the life of the record.
|
||||
func NewUID() string {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return fmt.Sprintf("t%d", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// NewMachineID mints this installation's id, once. The name is the operator's
|
||||
// (they may call it "shack" or "portable"); the suffix keeps two machines named
|
||||
// the same from writing to one file.
|
||||
func NewMachineID(name string) string {
|
||||
var b [4]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
n := sanitiseName(name)
|
||||
if n == "" {
|
||||
n = "opslog"
|
||||
}
|
||||
return n + "-" + hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// sanitiseName keeps a machine id usable as a FILENAME on every platform the
|
||||
// folder may be synced across — a Windows name written to a Linux NAS and back.
|
||||
func sanitiseName(s string) string {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
case r == '-' || r == '_' || r == ' ':
|
||||
b.WriteByte('-')
|
||||
}
|
||||
}
|
||||
out := strings.Trim(b.String(), "-")
|
||||
for strings.Contains(out, "--") {
|
||||
out = strings.ReplaceAll(out, "--", "-")
|
||||
}
|
||||
if len(out) > 24 {
|
||||
out = strings.Trim(out[:24], "-")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Wins decides between two changes to the same contact.
|
||||
//
|
||||
// Last writer wins, but ordered on (At, Seq, By) rather than on time alone.
|
||||
// Two PCs' clocks are never exactly equal and one may be minutes out, so a bare
|
||||
// timestamp comparison is not even deterministic: two machines merging the same
|
||||
// pair in different orders could reach different answers and then disagree for
|
||||
// ever. Adding the writer's own counter and finally its id makes the order
|
||||
// total — every machine reaches the same conclusion from the same records,
|
||||
// whatever sequence they arrive in.
|
||||
//
|
||||
// Clock skew still decides WHICH edit wins, and nothing here can fix that. What
|
||||
// it guarantees is that all machines agree on the winner.
|
||||
func Wins(a, b Record) bool {
|
||||
if !a.At.Equal(b.At) {
|
||||
return a.At.After(b.At)
|
||||
}
|
||||
if a.Seq != b.Seq {
|
||||
return a.Seq > b.Seq
|
||||
}
|
||||
return a.By > b.By
|
||||
}
|
||||
|
||||
// Store is one machine's view of the shared folder.
|
||||
type Store struct {
|
||||
root string // the folder the operator picked
|
||||
machineID string
|
||||
}
|
||||
|
||||
// New returns a store. root is the operator's chosen folder; the package
|
||||
// creates and uses its own sub-folder inside it.
|
||||
func New(root, machineID string) *Store {
|
||||
return &Store{root: strings.TrimSpace(root), machineID: machineID}
|
||||
}
|
||||
|
||||
// Dir is where the files live.
|
||||
func (s *Store) Dir() string { return filepath.Join(s.root, DirName) }
|
||||
|
||||
// MyFile is the only file this machine ever writes.
|
||||
func (s *Store) MyFile() string { return filepath.Join(s.Dir(), s.machineID+".ndjson") }
|
||||
|
||||
// Append adds one record to this machine's file.
|
||||
//
|
||||
// Opened, written and closed per call, with O_APPEND: a sync client that
|
||||
// uploads the file between two contacts sees a complete file every time, and a
|
||||
// process killed mid-write loses at most the line it was writing.
|
||||
func (s *Store) Append(rec Record) error {
|
||||
if s.root == "" {
|
||||
return fmt.Errorf("syncfolder: no folder configured")
|
||||
}
|
||||
if err := os.MkdirAll(s.Dir(), 0o755); err != nil {
|
||||
return fmt.Errorf("syncfolder: create %s: %w", s.Dir(), err)
|
||||
}
|
||||
rec.V = FormatVersion
|
||||
rec.By = s.machineID
|
||||
if rec.At.IsZero() {
|
||||
rec.At = time.Now().UTC()
|
||||
}
|
||||
rec.At = rec.At.UTC()
|
||||
line, err := json.Marshal(rec)
|
||||
if err != nil {
|
||||
return fmt.Errorf("syncfolder: encode: %w", err)
|
||||
}
|
||||
f, err := os.OpenFile(s.MyFile(), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("syncfolder: open %s: %w", s.MyFile(), err)
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := f.Write(append(line, '\n')); err != nil {
|
||||
return fmt.Errorf("syncfolder: write: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Peer is another machine's file and how far this one has read it.
|
||||
type Peer struct {
|
||||
MachineID string
|
||||
Path string
|
||||
Size int64
|
||||
}
|
||||
|
||||
// Peers lists the other machines' files, skipping this machine's own.
|
||||
func (s *Store) Peers() ([]Peer, error) {
|
||||
if s.root == "" {
|
||||
return nil, fmt.Errorf("syncfolder: no folder configured")
|
||||
}
|
||||
entries, err := os.ReadDir(s.Dir())
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil // nobody has written anything yet
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var out []Peer
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".ndjson") {
|
||||
continue
|
||||
}
|
||||
id := strings.TrimSuffix(e.Name(), ".ndjson")
|
||||
if id == s.machineID {
|
||||
continue // never read our own back — that is how a loop starts
|
||||
}
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, Peer{MachineID: id, Path: filepath.Join(s.Dir(), e.Name()), Size: info.Size()})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ReadFrom returns the records in a peer's file after byte offset `from`, and
|
||||
// the offset to resume at next time.
|
||||
//
|
||||
// Resuming by byte offset is what keeps a sync cheap: a file with 40 000
|
||||
// contacts is read once, and thereafter only its tail. The returned offset
|
||||
// advances ONLY past complete lines — a file caught mid-upload ends in a
|
||||
// partial line, and stopping short of it means the next pass reads that record
|
||||
// whole instead of discarding it.
|
||||
func ReadFrom(path string, from int64) ([]Record, int64, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, from, err
|
||||
}
|
||||
// A file that SHRANK was replaced, not appended to — a sync conflict copy,
|
||||
// a restore, a machine id reused. Start again rather than read from an
|
||||
// offset that now points into the middle of a different record.
|
||||
if int64(len(data)) < from {
|
||||
from = 0
|
||||
}
|
||||
tail := data[from:]
|
||||
var recs []Record
|
||||
consumed := int64(0)
|
||||
for {
|
||||
i := indexByte(tail, '\n')
|
||||
if i < 0 {
|
||||
break // an incomplete final line: leave it for next time
|
||||
}
|
||||
line := tail[:i]
|
||||
tail = tail[i+1:]
|
||||
consumed += int64(i) + 1
|
||||
if len(strings.TrimSpace(string(line))) == 0 {
|
||||
continue
|
||||
}
|
||||
var rec Record
|
||||
if err := json.Unmarshal(line, &rec); err != nil {
|
||||
continue // one unreadable line must not stop the file
|
||||
}
|
||||
if rec.V > FormatVersion {
|
||||
continue // written by a newer OpsLog: skip, never guess
|
||||
}
|
||||
if rec.UID == "" || rec.Op == "" {
|
||||
continue
|
||||
}
|
||||
recs = append(recs, rec)
|
||||
}
|
||||
return recs, from + consumed, nil
|
||||
}
|
||||
|
||||
func indexByte(b []byte, c byte) int {
|
||||
for i := range b {
|
||||
if b[i] == c {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// Merge reduces a batch of records to ONE decision per contact — the winner.
|
||||
//
|
||||
// Applying every record in turn would work but would write the same row several
|
||||
// times over, and on a first sync of a large log that is thousands of pointless
|
||||
// updates. It also makes the result independent of the order the peers'
|
||||
// files happened to be read in.
|
||||
func Merge(recs []Record) map[string]Record {
|
||||
out := make(map[string]Record, len(recs))
|
||||
for _, r := range recs {
|
||||
cur, seen := out[r.UID]
|
||||
if !seen || Wins(r, cur) {
|
||||
out[r.UID] = r
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package syncfolder
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func rec(uid string, op Op, at string, seq uint64, by string) Record {
|
||||
t, _ := time.Parse(time.RFC3339, at)
|
||||
return Record{V: FormatVersion, Op: op, UID: uid, At: t, Seq: seq, By: by}
|
||||
}
|
||||
|
||||
// A machine must never read its own file back. That is how a change loops
|
||||
// round the folder for ever, each pass re-applying what this machine wrote.
|
||||
func TestPeersExcludesOurselves(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
s := New(root, "shack-aabbccdd")
|
||||
if err := s.Append(rec("u1", OpAdd, "2026-08-16T10:00:00Z", 1, "")); err != nil {
|
||||
t.Fatalf("append: %v", err)
|
||||
}
|
||||
must(t, os.WriteFile(filepath.Join(s.Dir(), "laptop-11223344.ndjson"), []byte("{}\n"), 0o644))
|
||||
|
||||
peers, err := s.Peers()
|
||||
if err != nil {
|
||||
t.Fatalf("peers: %v", err)
|
||||
}
|
||||
if len(peers) != 1 || peers[0].MachineID != "laptop-11223344" {
|
||||
t.Fatalf("peers = %+v — our own file must not be among them", peers)
|
||||
}
|
||||
}
|
||||
|
||||
// Reading resumes from a byte offset, and only ever advances past COMPLETE
|
||||
// lines. A folder sync catches a file mid-upload sooner or later, and the
|
||||
// partial last line must be read whole on the next pass, not thrown away.
|
||||
func TestReadFromStopsAtAnIncompleteLine(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "peer.ndjson")
|
||||
|
||||
full, _ := json.Marshal(rec("u1", OpAdd, "2026-08-16T10:00:00Z", 1, "peer"))
|
||||
partial, _ := json.Marshal(rec("u2", OpAdd, "2026-08-16T10:01:00Z", 2, "peer"))
|
||||
must(t, os.WriteFile(path, append(append(full, '\n'), partial[:len(partial)/2]...), 0o644))
|
||||
|
||||
recs, off, err := ReadFrom(path, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFrom: %v", err)
|
||||
}
|
||||
if len(recs) != 1 || recs[0].UID != "u1" {
|
||||
t.Fatalf("got %d record(s) %+v, want just u1", len(recs), recs)
|
||||
}
|
||||
if off != int64(len(full))+1 {
|
||||
t.Fatalf("offset %d, want %d — it must stop before the partial line", off, len(full)+1)
|
||||
}
|
||||
|
||||
// The upload completes; the second record is now read whole.
|
||||
must(t, os.WriteFile(path, append(append(append(full, '\n'), partial...), '\n'), 0o644))
|
||||
recs, off2, err := ReadFrom(path, off)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFrom 2: %v", err)
|
||||
}
|
||||
if len(recs) != 1 || recs[0].UID != "u2" {
|
||||
t.Fatalf("resumed with %+v, want just u2", recs)
|
||||
}
|
||||
if off2 <= off {
|
||||
t.Errorf("offset did not advance: %d → %d", off, off2)
|
||||
}
|
||||
}
|
||||
|
||||
// A file that shrank was replaced, not appended to — a sync conflict copy, a
|
||||
// restore. Resuming at the old offset would read from the middle of a record.
|
||||
func TestReadFromRestartsIfTheFileShrank(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "peer.ndjson")
|
||||
line, _ := json.Marshal(rec("u1", OpAdd, "2026-08-16T10:00:00Z", 1, "peer"))
|
||||
must(t, os.WriteFile(path, append(line, '\n'), 0o644))
|
||||
|
||||
recs, _, err := ReadFrom(path, 999999)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFrom: %v", err)
|
||||
}
|
||||
if len(recs) != 1 {
|
||||
t.Errorf("got %d record(s) — a shrunken file must be re-read from the start", len(recs))
|
||||
}
|
||||
}
|
||||
|
||||
// One unreadable line must not cost the rest of the file, and a record from a
|
||||
// FUTURE format must be skipped rather than guessed at.
|
||||
func TestReadFromSurvivesRubbish(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "peer.ndjson")
|
||||
good, _ := json.Marshal(rec("u1", OpAdd, "2026-08-16T10:00:00Z", 1, "peer"))
|
||||
future, _ := json.Marshal(Record{V: FormatVersion + 1, Op: OpAdd, UID: "u9", At: time.Now().UTC()})
|
||||
body := append([]byte("this is not json\n"), append(good, '\n')...)
|
||||
body = append(body, append(future, '\n')...)
|
||||
body = append(body, []byte("\n")...) // a blank line
|
||||
must(t, os.WriteFile(path, body, 0o644))
|
||||
|
||||
recs, _, err := ReadFrom(path, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFrom: %v", err)
|
||||
}
|
||||
if len(recs) != 1 || recs[0].UID != "u1" {
|
||||
t.Fatalf("got %+v, want only the one readable current-format record", recs)
|
||||
}
|
||||
}
|
||||
|
||||
// Every machine must reach the SAME winner from the same records, whatever
|
||||
// order they arrive in. Ordering on time alone is not even deterministic: two
|
||||
// PCs' clocks are never equal, and a tie would be resolved differently on each
|
||||
// machine, which is how two logs disagree for ever.
|
||||
func TestMergeIsOrderIndependent(t *testing.T) {
|
||||
a := rec("u1", OpUpdate, "2026-08-16T10:00:00Z", 5, "shack")
|
||||
b := rec("u1", OpDelete, "2026-08-16T10:00:00Z", 5, "laptop") // same time AND seq
|
||||
c := rec("u1", OpUpdate, "2026-08-16T09:00:00Z", 9, "shack") // older, higher seq
|
||||
|
||||
forward := Merge([]Record{a, b, c})
|
||||
reverse := Merge([]Record{c, b, a})
|
||||
if forward["u1"].By != reverse["u1"].By || forward["u1"].Op != reverse["u1"].Op {
|
||||
t.Fatalf("order changed the winner: %+v vs %+v", forward["u1"], reverse["u1"])
|
||||
}
|
||||
// Time beats sequence: c is an hour older whatever its counter says.
|
||||
if forward["u1"].At.Equal(c.At) {
|
||||
t.Error("an older change won on its counter — time is the first key")
|
||||
}
|
||||
}
|
||||
|
||||
// A delete is a record like any other, and it must be able to WIN. Without
|
||||
// tombstones a QSO removed on one machine returns on the next sync from
|
||||
// another, for ever.
|
||||
func TestATombstoneCanWin(t *testing.T) {
|
||||
add := rec("u1", OpAdd, "2026-08-16T10:00:00Z", 1, "shack")
|
||||
del := rec("u1", OpDelete, "2026-08-16T11:00:00Z", 1, "laptop")
|
||||
if got := Merge([]Record{add, del})["u1"]; got.Op != OpDelete {
|
||||
t.Errorf("winner is %q — a later deletion must beat an earlier add", got.Op)
|
||||
}
|
||||
// …and an add made AFTER a deletion wins, so re-logging a contact works.
|
||||
readd := rec("u1", OpAdd, "2026-08-16T12:00:00Z", 2, "laptop")
|
||||
if got := Merge([]Record{add, del, readd})["u1"]; got.Op != OpAdd {
|
||||
t.Errorf("winner is %q — a contact logged again after a deletion must come back", got.Op)
|
||||
}
|
||||
}
|
||||
|
||||
// The machine id becomes a FILENAME, on a folder that may be synced between
|
||||
// Windows, Linux and macOS. Anything that cannot be a filename everywhere has
|
||||
// to go.
|
||||
func TestMachineIDIsAUsableFilename(t *testing.T) {
|
||||
for _, name := range []string{"Shack PC", "portable/rig", "Café ☕", "", " ", "a::b*c?", strings.Repeat("x", 60)} {
|
||||
id := NewMachineID(name)
|
||||
for _, bad := range []string{"/", "\\", ":", "*", "?", "\"", "<", ">", "|", " "} {
|
||||
if strings.Contains(id, bad) {
|
||||
t.Errorf("NewMachineID(%q) = %q contains %q", name, id, bad)
|
||||
}
|
||||
}
|
||||
if id == "" || strings.HasPrefix(id, "-") || strings.HasSuffix(id, "-") {
|
||||
t.Errorf("NewMachineID(%q) = %q", name, id)
|
||||
}
|
||||
}
|
||||
// Two machines the operator called the same must not share a file.
|
||||
if NewMachineID("shack") == NewMachineID("shack") {
|
||||
t.Error("two installations named alike produced the same id — they would write to one file")
|
||||
}
|
||||
}
|
||||
|
||||
func must(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,620 @@
|
||||
// Package tciserver shares OpsLog's CAT link with programs that speak TCI.
|
||||
//
|
||||
// It is the second half of internal/rigctld, and exists for the same reason:
|
||||
// Windows gives a COM port to ONE process, so the moment OpsLog talks to the
|
||||
// radio directly nothing else can. rigctld answers the programs that speak
|
||||
// Hamlib NET rigctl (WSJT-X, JTDX, MSHV, Log4OM); this answers the ones built
|
||||
// around Expert Electronics' TCI instead — and it answers them whatever radio
|
||||
// is actually connected, because it sits on the same backend-agnostic
|
||||
// interface. An operator with an Icom or a Yaesu can hand a TCI-only program a
|
||||
// working rig.
|
||||
//
|
||||
// ── The protocol ──────────────────────────────────────────────────────────
|
||||
// Text commands over a WebSocket, "name:arg,arg;", the same syntax in both
|
||||
// directions. On connection the server sends a block of initialisation
|
||||
// commands describing the device, ending with ready; and start;. Thereafter
|
||||
// either side may send a control command, and the server echoes every change
|
||||
// to all connected clients so they stay in step with each other.
|
||||
//
|
||||
// vfo:0,0,14074000; receiver 0, channel A (RX), Hz
|
||||
// vfo:0,1,14080000; channel B — the TX frequency when split is on
|
||||
// modulation:0,usb; mode
|
||||
// trx:0,true; PTT
|
||||
// split_enable:0,true; split
|
||||
// vfo:0,0; a READ: the reply is the three-argument form
|
||||
//
|
||||
// Written against the official TCI Protocol document (ExpertSDR3/TCI, 12
|
||||
// January 2024, MIT) — the initialisation set and the argument order of every
|
||||
// command below are from §4.1 and §4.2, not from guesswork about what a client
|
||||
// might accept.
|
||||
package tciserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// Rig is what the server needs from OpsLog's CAT manager. An interface, so this
|
||||
// package stays testable without a radio and without importing internal/cat —
|
||||
// which also keeps it building on every platform.
|
||||
type Rig interface {
|
||||
Freq() int64 // TX frequency in Hz (ADIF sense), 0 if unknown
|
||||
RxFreq() int64 // RX frequency in Hz; equals Freq when not split
|
||||
Mode() string // ADIF mode (SSB, CW, FT8…)
|
||||
Split() (bool, int64) // split on?, and the TX frequency
|
||||
SetFreq(hz int64) error
|
||||
SetMode(mode string) error
|
||||
SetPTT(on bool) error
|
||||
SetSplit(on bool, txHz int64) error
|
||||
}
|
||||
|
||||
// DefaultPort is TCI's own default, which is what a client offers first.
|
||||
const DefaultPort = 40001
|
||||
|
||||
// pollInterval is how often the rig is compared with what the clients were last
|
||||
// told. TCI is an event protocol — a client is entitled to sit silent and be
|
||||
// told when something moves — so this is the rate at which a knob turned on the
|
||||
// radio reaches it.
|
||||
const pollInterval = 250 * time.Millisecond
|
||||
|
||||
type Server struct {
|
||||
port int
|
||||
rig Rig
|
||||
log func(string, ...any)
|
||||
|
||||
mu sync.Mutex
|
||||
ln net.Listener
|
||||
http *http.Server
|
||||
conns map[*client]struct{}
|
||||
closed bool
|
||||
|
||||
// pendingTxHz is a transmit frequency a client set on channel B while the rig
|
||||
// was still simplex.
|
||||
//
|
||||
// It must be REMEMBERED, not discarded. A client working split sends two
|
||||
// commands and is free to send them in either order; when the frequency comes
|
||||
// first, throwing it away means the split is then armed on whatever the
|
||||
// transmit VFO happened to hold — the receive frequency — and the operator
|
||||
// transmits straight onto the DX while their software shows exactly what they
|
||||
// asked for. rigctld learned this the same way, and pairs set_split_vfo with
|
||||
// set_split_freq for the same reason.
|
||||
pendingTxHz int64
|
||||
|
||||
// ptt mirrors the last PTT state a client commanded, so a repeat can be
|
||||
// recognised. A client is free to restate PTT as often as it likes, and one
|
||||
// does: through the rigctl server Nexus sent set_ptt 0 about sixteen times a
|
||||
// second, and the Flex's own "xmit 1" landed between two of them and was
|
||||
// overwritten inside a millisecond — a transmit request that simply did
|
||||
// nothing. The same radio sits behind this server.
|
||||
ptt bool
|
||||
pttKnown bool
|
||||
|
||||
// last is what the clients have been told, so only changes are sent. TCI
|
||||
// clients redraw on every command they receive; re-sending an unchanged
|
||||
// frequency four times a second makes a VFO readout flicker and, in some
|
||||
// clients, fights the operator's own tuning.
|
||||
last state
|
||||
}
|
||||
|
||||
// state is the part of the rig the clients are kept in step with.
|
||||
type state struct {
|
||||
rxHz int64
|
||||
txHz int64
|
||||
mode string
|
||||
split bool
|
||||
valid bool
|
||||
}
|
||||
|
||||
// clientLogCap bounds how many of one client's commands reach the log.
|
||||
const clientLogCap = 200
|
||||
|
||||
// client is one connected program.
|
||||
type client struct {
|
||||
conn *websocket.Conn
|
||||
mu sync.Mutex // one writer at a time: gorilla panics on concurrent writes
|
||||
// logged counts what has been written to the log for this connection. Only
|
||||
// the reader goroutine touches it, so it needs no lock of its own.
|
||||
logged int
|
||||
}
|
||||
|
||||
func (c *client) send(s string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.conn == nil {
|
||||
return nil // a client with no socket: the tests exercise the protocol, not the transport
|
||||
}
|
||||
_ = c.conn.SetWriteDeadline(time.Now().Add(3 * time.Second))
|
||||
return c.conn.WriteMessage(websocket.TextMessage, []byte(s))
|
||||
}
|
||||
|
||||
func New(port int, rig Rig, logf func(string, ...any)) *Server {
|
||||
if port <= 0 || port > 65535 {
|
||||
port = DefaultPort
|
||||
}
|
||||
if logf == nil {
|
||||
logf = func(string, ...any) {}
|
||||
}
|
||||
return &Server{port: port, rig: rig, log: logf, conns: map[*client]struct{}{}}
|
||||
}
|
||||
|
||||
// Start binds the port and serves until Stop.
|
||||
func (s *Server) Start() error {
|
||||
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", s.port))
|
||||
if err != nil {
|
||||
return fmt.Errorf("tci server: port %d: %w", s.port, err)
|
||||
}
|
||||
up := websocket.Upgrader{
|
||||
// Any origin: the clients are desktop programs on the same machine or
|
||||
// LAN, and they send whatever Origin their toolkit happens to set. This
|
||||
// is the same trust boundary as the rigctl server on 4532 — a plain TCP
|
||||
// port with no authentication, which is what every logger expects.
|
||||
CheckOrigin: func(*http.Request) bool { return true },
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
// Any path: clients connect to ws://host:port/ but some append a name.
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := up.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
s.log("tci server: upgrade from %s failed: %v", r.RemoteAddr, err)
|
||||
return
|
||||
}
|
||||
s.serve(&client{conn: conn}, r.RemoteAddr)
|
||||
})
|
||||
srv := &http.Server{Handler: mux}
|
||||
s.mu.Lock()
|
||||
s.ln, s.http, s.closed = ln, srv, false
|
||||
s.mu.Unlock()
|
||||
go func() { _ = srv.Serve(ln) }()
|
||||
go s.pushLoop()
|
||||
s.log("tci server: listening on :%d", s.port)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop closes the listener and every client.
|
||||
func (s *Server) Stop() {
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.closed = true
|
||||
ln, srv := s.ln, s.http
|
||||
conns := make([]*client, 0, len(s.conns))
|
||||
for c := range s.conns {
|
||||
conns = append(conns, c)
|
||||
}
|
||||
s.conns = map[*client]struct{}{}
|
||||
s.last = state{}
|
||||
s.mu.Unlock()
|
||||
for _, c := range conns {
|
||||
_ = c.conn.Close()
|
||||
}
|
||||
if srv != nil {
|
||||
_ = srv.Close()
|
||||
}
|
||||
if ln != nil {
|
||||
_ = ln.Close()
|
||||
}
|
||||
s.log("tci server: stopped")
|
||||
}
|
||||
|
||||
// Clients reports how many programs are connected — the one thing an operator
|
||||
// wants to know when a client says it cannot find the rig.
|
||||
func (s *Server) Clients() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return len(s.conns)
|
||||
}
|
||||
|
||||
// serve runs one connection: the initialisation block, then commands until it
|
||||
// closes.
|
||||
func (s *Server) serve(c *client, remote string) {
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
s.mu.Unlock()
|
||||
_ = c.conn.Close()
|
||||
return
|
||||
}
|
||||
s.conns[c] = struct{}{}
|
||||
s.mu.Unlock()
|
||||
s.log("tci server: %s connected", remote)
|
||||
|
||||
for _, line := range s.initBlock() {
|
||||
if err := c.send(line); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
for {
|
||||
_, data, err := c.conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
// One frame may carry several ";"-terminated commands.
|
||||
for _, cmd := range strings.Split(string(data), ";") {
|
||||
if cmd = strings.TrimSpace(cmd); cmd == "" {
|
||||
continue
|
||||
}
|
||||
// Every command the client sends, in the log.
|
||||
//
|
||||
// This is the only evidence there will ever be about a program on
|
||||
// someone else's machine: "MSHV's PTT test does nothing" is
|
||||
// unanswerable without knowing whether MSHV sent trx at all, and if
|
||||
// so in what form. Cheap, because TCI is event-driven — a client
|
||||
// speaks when the operator does something, not on a timer.
|
||||
//
|
||||
// Capped so a client that DOES poll cannot quietly fill the
|
||||
// operator's log; the cap says so once and then stays quiet.
|
||||
if c.logged < clientLogCap {
|
||||
c.logged++
|
||||
s.log("tci server: ← %s;", cmd)
|
||||
} else if c.logged == clientLogCap {
|
||||
c.logged++
|
||||
s.log("tci server: (further commands from this client are not logged)")
|
||||
}
|
||||
s.handle(c, cmd)
|
||||
}
|
||||
}
|
||||
s.mu.Lock()
|
||||
delete(s.conns, c)
|
||||
s.mu.Unlock()
|
||||
_ = c.conn.Close()
|
||||
s.log("tci server: %s disconnected", remote)
|
||||
}
|
||||
|
||||
// initBlock is the initialisation set from §4.1 of the protocol document, in
|
||||
// the documented order, followed by the current state so a client that has just
|
||||
// connected shows the right frequency instead of waiting for the first change.
|
||||
//
|
||||
// A client will not proceed without these: they are how it learns the device
|
||||
// exists, what it can do, and that the server has finished setting up.
|
||||
func (s *Server) initBlock() []string {
|
||||
rx, tx, mode, split := s.read()
|
||||
return []string{
|
||||
"protocol:ExpertSDR3,1.9;",
|
||||
"device:OpsLog;",
|
||||
"receive_only:false;",
|
||||
"trx_count:1;",
|
||||
"channel_count:2;",
|
||||
// The whole HF/VHF/UHF span OpsLog itself works over. A client uses this
|
||||
// to bound its own tuning; too narrow a range and it refuses to follow the
|
||||
// rig onto 2 m.
|
||||
"vfo_limits:10000,470000000;",
|
||||
"if_limits:-48000,48000;",
|
||||
"modulations_list:am,sam,dsb,lsb,usb,cw,nfm,digl,digu;",
|
||||
"ready;",
|
||||
"start;",
|
||||
fmt.Sprintf("vfo:0,0,%d;", rx),
|
||||
fmt.Sprintf("vfo:0,1,%d;", tx),
|
||||
fmt.Sprintf("modulation:0,%s;", mode),
|
||||
fmt.Sprintf("split_enable:0,%t;", split),
|
||||
"trx:0,false;",
|
||||
// TRANSMIT PERMISSION, and it is not optional in practice.
|
||||
//
|
||||
// The document files TX_ENABLE under unidirectional control rather than
|
||||
// initialisation, but its own note says it is "sent to the client when
|
||||
// connected". A client that models permission — and one written for
|
||||
// ExpertSDR users has every reason to — starts out assuming it may NOT
|
||||
// transmit, and without this it never even tries: PTT does nothing and
|
||||
// the server never sees a trx command to refuse.
|
||||
//
|
||||
// Always true. OpsLog is not the thing that decides: the radio behind
|
||||
// whichever backend is connected does, and its refusal comes back through
|
||||
// SetPTT and into the log.
|
||||
"tx_enable:0,true;",
|
||||
fmt.Sprintf("tx_frequency:%d;", tx),
|
||||
}
|
||||
}
|
||||
|
||||
// read takes one consistent snapshot of the rig in TCI's terms: channel A is
|
||||
// where we LISTEN and channel B where we transmit, which is the opposite way
|
||||
// round from ADIF's RigState and the one mistake here that would make a client
|
||||
// transmit on the DX's frequency.
|
||||
func (s *Server) read() (rxHz, txHz int64, mode string, split bool) {
|
||||
split, txHz = s.rig.Split()
|
||||
rxHz = s.rig.RxFreq()
|
||||
if !split {
|
||||
txHz = s.rig.Freq()
|
||||
if rxHz == 0 {
|
||||
rxHz = txHz
|
||||
}
|
||||
}
|
||||
if rxHz == 0 {
|
||||
rxHz = s.rig.Freq()
|
||||
}
|
||||
if txHz == 0 {
|
||||
txHz = rxHz
|
||||
}
|
||||
mode = adifToTCIMode(s.rig.Mode(), rxHz)
|
||||
return rxHz, txHz, mode, split
|
||||
}
|
||||
|
||||
// pushLoop tells the clients what has changed on the radio.
|
||||
func (s *Server) pushLoop() {
|
||||
t := time.NewTicker(pollInterval)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
s.mu.Lock()
|
||||
done := s.closed
|
||||
s.mu.Unlock()
|
||||
if done {
|
||||
return
|
||||
}
|
||||
s.publish()
|
||||
}
|
||||
}
|
||||
|
||||
// publish sends only what moved. Returns the lines sent, for the tests.
|
||||
func (s *Server) publish() []string {
|
||||
rx, tx, mode, split := s.read()
|
||||
cur := state{rxHz: rx, txHz: tx, mode: mode, split: split, valid: true}
|
||||
|
||||
s.mu.Lock()
|
||||
prev := s.last
|
||||
s.last = cur
|
||||
s.mu.Unlock()
|
||||
|
||||
var lines []string
|
||||
if !prev.valid || prev.rxHz != cur.rxHz {
|
||||
lines = append(lines, fmt.Sprintf("vfo:0,0,%d;", cur.rxHz))
|
||||
}
|
||||
if !prev.valid || prev.txHz != cur.txHz {
|
||||
lines = append(lines, fmt.Sprintf("vfo:0,1,%d;", cur.txHz))
|
||||
// The transmit frequency has its own command, which is what a client
|
||||
// showing "TX 14.080" reads. Channel B alone leaves that stale.
|
||||
lines = append(lines, fmt.Sprintf("tx_frequency:%d;", cur.txHz))
|
||||
}
|
||||
if (!prev.valid || prev.mode != cur.mode) && cur.mode != "" {
|
||||
lines = append(lines, fmt.Sprintf("modulation:0,%s;", cur.mode))
|
||||
}
|
||||
if !prev.valid || prev.split != cur.split {
|
||||
lines = append(lines, fmt.Sprintf("split_enable:0,%t;", cur.split))
|
||||
}
|
||||
for _, l := range lines {
|
||||
s.broadcast(l)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func (s *Server) broadcast(line string) {
|
||||
s.mu.Lock()
|
||||
conns := make([]*client, 0, len(s.conns))
|
||||
for c := range s.conns {
|
||||
conns = append(conns, c)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
for _, c := range conns {
|
||||
_ = c.send(line)
|
||||
}
|
||||
}
|
||||
|
||||
// handle answers one command from a client. Returns what was sent back, which
|
||||
// is "" for a command that only acts on the radio.
|
||||
//
|
||||
// A command that SETS something is echoed to every client, not just answered to
|
||||
// the one that sent it: the protocol document is explicit that the server
|
||||
// synchronises all connected clients, and two loggers that disagree about the
|
||||
// frequency are worse than one that is merely slow.
|
||||
func (s *Server) handle(c *client, cmd string) string {
|
||||
name, args := cmd, ""
|
||||
if i := strings.IndexByte(cmd, ':'); i >= 0 {
|
||||
name, args = cmd[:i], cmd[i+1:]
|
||||
}
|
||||
f := strings.Split(args, ",")
|
||||
arg := func(i int) string {
|
||||
if i < len(f) {
|
||||
return strings.TrimSpace(f[i])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
num := func(i int) int64 {
|
||||
v, _ := strconv.ParseInt(arg(i), 10, 64)
|
||||
return v
|
||||
}
|
||||
reply := func(line string) string {
|
||||
_ = c.send(line)
|
||||
return line
|
||||
}
|
||||
rx, tx, mode, split := s.read()
|
||||
|
||||
switch strings.ToLower(strings.TrimSpace(name)) {
|
||||
case "vfo":
|
||||
// Read form: two arguments. Set form: three.
|
||||
if len(f) < 3 || arg(2) == "" {
|
||||
if arg(1) == "1" {
|
||||
return reply(fmt.Sprintf("vfo:0,1,%d;", tx))
|
||||
}
|
||||
return reply(fmt.Sprintf("vfo:0,0,%d;", rx))
|
||||
}
|
||||
hz := num(2)
|
||||
if hz <= 0 {
|
||||
return ""
|
||||
}
|
||||
if arg(1) == "1" {
|
||||
// Channel B is the transmit frequency. Setting it while simplex must
|
||||
// not move the rig's only VFO — the client asked to prepare a split
|
||||
// transmit frequency, not to QSY — but it must not be thrown away
|
||||
// either: it is where the split will be armed a moment from now.
|
||||
s.mu.Lock()
|
||||
s.pendingTxHz = hz
|
||||
s.mu.Unlock()
|
||||
if !split {
|
||||
s.broadcast(fmt.Sprintf("vfo:0,1,%d;", hz))
|
||||
return ""
|
||||
}
|
||||
if err := s.rig.SetSplit(true, hz); err != nil {
|
||||
s.log("tci server: split TX %d Hz refused: %v", hz, err)
|
||||
return ""
|
||||
}
|
||||
} else if err := s.rig.SetFreq(hz); err != nil {
|
||||
s.log("tci server: tune to %d Hz refused: %v", hz, err)
|
||||
return ""
|
||||
}
|
||||
s.broadcast(fmt.Sprintf("vfo:0,%s,%d;", orZero(arg(1)), hz))
|
||||
return ""
|
||||
|
||||
case "modulation":
|
||||
if len(f) < 2 || arg(1) == "" {
|
||||
return reply(fmt.Sprintf("modulation:0,%s;", mode))
|
||||
}
|
||||
m := tciModeToADIF(arg(1))
|
||||
if m == "" {
|
||||
return ""
|
||||
}
|
||||
if err := s.rig.SetMode(m); err != nil {
|
||||
s.log("tci server: mode %s refused: %v", m, err)
|
||||
return ""
|
||||
}
|
||||
s.broadcast(fmt.Sprintf("modulation:0,%s;", strings.ToLower(arg(1))))
|
||||
return ""
|
||||
|
||||
case "trx":
|
||||
if len(f) < 2 || arg(1) == "" {
|
||||
return reply("trx:0,false;")
|
||||
}
|
||||
on := strings.EqualFold(arg(1), "true")
|
||||
// Only touch the radio on a CHANGE — restating a state is not a request
|
||||
// to change it. The first command always goes through, since there is no
|
||||
// knowing how the radio was left.
|
||||
s.mu.Lock()
|
||||
known, prev := s.pttKnown, s.ptt
|
||||
s.ptt, s.pttKnown = on, true
|
||||
s.mu.Unlock()
|
||||
if known && prev == on {
|
||||
s.broadcast(fmt.Sprintf("trx:0,%t;", on))
|
||||
return ""
|
||||
}
|
||||
if err := s.rig.SetPTT(on); err != nil {
|
||||
s.log("tci server: PTT %v refused: %v", on, err)
|
||||
return ""
|
||||
}
|
||||
s.log("tci server: PTT %s", map[bool]string{true: "ON", false: "off"}[on])
|
||||
s.broadcast(fmt.Sprintf("trx:0,%t;", on))
|
||||
return ""
|
||||
|
||||
case "split_enable":
|
||||
if len(f) < 2 || arg(1) == "" {
|
||||
return reply(fmt.Sprintf("split_enable:0,%t;", split))
|
||||
}
|
||||
on := strings.EqualFold(arg(1), "true")
|
||||
// Already in the state asked for? Then it is done, and nothing goes to
|
||||
// the radio. This is the lesson the rigctl server paid for: JTDX in "Fake
|
||||
// It" uses no split but still says so to be sure, and a backend that
|
||||
// cannot set split answered an error to a request that was already true.
|
||||
// JTDX read that as rig control failing and abandoned the transmission a
|
||||
// second into the frame. A refusal is only honest when something actually
|
||||
// needed doing.
|
||||
if on == split {
|
||||
s.broadcast(fmt.Sprintf("split_enable:0,%t;", on))
|
||||
return ""
|
||||
}
|
||||
// Arm on the frequency the client gave for channel B, which it is free to
|
||||
// have sent before this command rather than after.
|
||||
s.mu.Lock()
|
||||
pending := s.pendingTxHz
|
||||
s.mu.Unlock()
|
||||
txHz := tx
|
||||
if on && pending > 0 {
|
||||
txHz = pending
|
||||
}
|
||||
if err := s.rig.SetSplit(on, txHz); err != nil {
|
||||
// The refusal is the useful part: a backend that cannot split says
|
||||
// so, and the client can tell the operator instead of transmitting
|
||||
// on the wrong frequency believing all is well.
|
||||
s.log("tci server: split %v refused: %v", on, err)
|
||||
return ""
|
||||
}
|
||||
s.log("tci server: split %s, TX %d Hz", map[bool]string{true: "ON", false: "off"}[on], txHz)
|
||||
s.broadcast(fmt.Sprintf("split_enable:0,%t;", on))
|
||||
return ""
|
||||
|
||||
case "dds":
|
||||
// The panorama's centre frequency. OpsLog has no panorama, so it answers
|
||||
// with the receive frequency — which is where a client draws its own.
|
||||
return reply(fmt.Sprintf("dds:0,%d;", rx))
|
||||
|
||||
case "if":
|
||||
// Offset of the tuning filter inside the panorama: zero, since our "dds"
|
||||
// is the receive frequency itself.
|
||||
return reply("if:0,0,0;")
|
||||
|
||||
case "start", "stop", "ready":
|
||||
return ""
|
||||
|
||||
default:
|
||||
// Everything else — audio streams, CW macros, the E-Coder, the
|
||||
// panorama's own settings — belongs to a radio, not to a CAT link.
|
||||
// Silence rather than an error: a client sends these hopefully at
|
||||
// connect, and a refusal it did not ask for reads as a fault.
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func orZero(s string) string {
|
||||
if s == "" {
|
||||
return "0"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// adifToTCIMode maps an ADIF mode to a TCI modulation.
|
||||
//
|
||||
// SSB carries no sideband, so it is resolved from the frequency the way every
|
||||
// operator does: below 10 MHz lower, above it upper. A client told "ssb" would
|
||||
// not recognise it — the modulation list is the vocabulary.
|
||||
func adifToTCIMode(mode string, hz int64) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||
case "":
|
||||
return ""
|
||||
case "CW", "CWR":
|
||||
return "cw"
|
||||
case "USB":
|
||||
return "usb"
|
||||
case "LSB":
|
||||
return "lsb"
|
||||
case "SSB":
|
||||
if hz > 0 && hz < 10_000_000 {
|
||||
return "lsb"
|
||||
}
|
||||
return "usb"
|
||||
case "AM":
|
||||
return "am"
|
||||
case "FM", "NFM":
|
||||
return "nfm"
|
||||
case "RTTY":
|
||||
return "digl"
|
||||
}
|
||||
// Everything else is a data mode: FT8, FT4, JT65, PSK31, MSK144, VARA…
|
||||
// TCI has one pair for the whole family, and the sideband follows the same
|
||||
// rule the data modes themselves use — upper, but for the few HF corners
|
||||
// where LSB is conventional the radio is already there.
|
||||
return "digu"
|
||||
}
|
||||
|
||||
// tciModeToADIF maps a TCI modulation back to an ADIF mode.
|
||||
func tciModeToADIF(m string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(m)) {
|
||||
case "cw":
|
||||
return "CW"
|
||||
case "usb":
|
||||
return "USB"
|
||||
case "lsb":
|
||||
return "LSB"
|
||||
case "am", "sam":
|
||||
return "AM"
|
||||
case "nfm", "fm", "wfm":
|
||||
return "FM"
|
||||
case "digl", "digu", "dsb", "drm":
|
||||
// The data family: the mode the operator is actually running (FT8, RTTY)
|
||||
// is chosen in OpsLog, and a client switching to "digital" must not
|
||||
// overwrite it with a guess. DATA is the honest ADIF answer.
|
||||
return "DATA"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
package tciserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeRig is a radio that remembers what it was told. Everything here is about
|
||||
// what OpsLog does with a client's command, so the rig only has to answer and
|
||||
// record.
|
||||
type fakeRig struct {
|
||||
freq, rxFreq int64
|
||||
mode string
|
||||
split bool
|
||||
txHz int64
|
||||
ptt bool
|
||||
splitErr error
|
||||
calls []string
|
||||
}
|
||||
|
||||
func (r *fakeRig) Freq() int64 { return r.freq }
|
||||
func (r *fakeRig) RxFreq() int64 { return r.rxFreq }
|
||||
func (r *fakeRig) Mode() string { return r.mode }
|
||||
func (r *fakeRig) Split() (bool, int64) { return r.split, r.txHz }
|
||||
func (r *fakeRig) SetFreq(hz int64) error {
|
||||
r.calls = append(r.calls, fmt.Sprintf("freq=%d", hz))
|
||||
r.freq, r.rxFreq = hz, hz
|
||||
return nil
|
||||
}
|
||||
func (r *fakeRig) SetMode(m string) error {
|
||||
r.calls = append(r.calls, "mode="+m)
|
||||
r.mode = m
|
||||
return nil
|
||||
}
|
||||
func (r *fakeRig) SetPTT(on bool) error {
|
||||
r.calls = append(r.calls, fmt.Sprintf("ptt=%v", on))
|
||||
r.ptt = on
|
||||
return nil
|
||||
}
|
||||
func (r *fakeRig) SetSplit(on bool, txHz int64) error {
|
||||
if r.splitErr != nil {
|
||||
return r.splitErr
|
||||
}
|
||||
r.calls = append(r.calls, fmt.Sprintf("split=%v,%d", on, txHz))
|
||||
r.split, r.txHz = on, txHz
|
||||
return nil
|
||||
}
|
||||
|
||||
// srv builds a server with no listener — handle() and publish() are the whole
|
||||
// protocol, and neither needs a socket.
|
||||
func srv(r *fakeRig) *Server { return New(0, r, nil) }
|
||||
|
||||
// A client with no connection: send() would need one, so reads are checked
|
||||
// through the returned line instead. This is why handle returns what it sent.
|
||||
func ask(t *testing.T, s *Server, cmd string) string {
|
||||
t.Helper()
|
||||
return s.handle(&client{}, cmd)
|
||||
}
|
||||
|
||||
// The initialisation block is what a client needs before it will believe there
|
||||
// is a radio at all. Its contents come from §4.1 of the protocol document, and
|
||||
// a client that does not see ready; simply waits for ever.
|
||||
func TestInitBlockCarriesTheDocumentedInitialisationSet(t *testing.T) {
|
||||
s := srv(&fakeRig{freq: 14074000, rxFreq: 14074000, mode: "USB"})
|
||||
block := strings.Join(s.initBlock(), "")
|
||||
for _, want := range []string{
|
||||
"protocol:ExpertSDR3,", "device:", "receive_only:false;", "trx_count:1;",
|
||||
"channel_count:2;", "vfo_limits:", "if_limits:", "modulations_list:",
|
||||
"ready;", "start;",
|
||||
// Transmit permission. A client that models it starts out assuming it
|
||||
// may NOT transmit, and without this never even tries — PTT does
|
||||
// nothing and the server never sees a trx command at all.
|
||||
"tx_enable:0,true;",
|
||||
} {
|
||||
if !strings.Contains(block, want) {
|
||||
t.Errorf("the initialisation block is missing %q — a client would not proceed past connect", want)
|
||||
}
|
||||
}
|
||||
// And the current state, so a client that connects mid-session shows the
|
||||
// right frequency instead of waiting for the operator to touch something.
|
||||
if !strings.Contains(block, "vfo:0,0,14074000;") {
|
||||
t.Errorf("no current frequency in the block:\n%s", block)
|
||||
}
|
||||
if !strings.Contains(block, "modulation:0,usb;") {
|
||||
t.Errorf("no current mode in the block:\n%s", block)
|
||||
}
|
||||
}
|
||||
|
||||
// Channel A is where we LISTEN, channel B where we transmit. Handing these to a
|
||||
// client the wrong way round is the one mistake here that puts a station on the
|
||||
// DX's own frequency, so it is pinned in both directions.
|
||||
func TestSplitPutsTheListeningFrequencyOnChannelA(t *testing.T) {
|
||||
// OpsLog's RigState is ADIF: Freq is the TRANSMIT frequency, RxFreq where we
|
||||
// listen. A DX transmitting on 14025 and listening up 2.
|
||||
r := &fakeRig{freq: 14027000, rxFreq: 14025000, mode: "CW", split: true, txHz: 14027000}
|
||||
s := srv(r)
|
||||
block := strings.Join(s.initBlock(), "")
|
||||
if !strings.Contains(block, "vfo:0,0,14025000;") {
|
||||
t.Errorf("channel A is not the receive frequency:\n%s", block)
|
||||
}
|
||||
if !strings.Contains(block, "vfo:0,1,14027000;") {
|
||||
t.Errorf("channel B is not the transmit frequency:\n%s", block)
|
||||
}
|
||||
if !strings.Contains(block, "split_enable:0,true;") {
|
||||
t.Errorf("split was not announced:\n%s", block)
|
||||
}
|
||||
}
|
||||
|
||||
// Simplex: both channels report the one frequency, so a client reading either
|
||||
// gets the right answer.
|
||||
func TestSimplexReportsTheSameFrequencyOnBothChannels(t *testing.T) {
|
||||
s := srv(&fakeRig{freq: 7100000, rxFreq: 7100000, mode: "SSB"})
|
||||
if got := ask(t, s, "vfo:0,0"); got != "vfo:0,0,7100000;" {
|
||||
t.Errorf("read of channel A = %q", got)
|
||||
}
|
||||
if got := ask(t, s, "vfo:0,1"); got != "vfo:0,1,7100000;" {
|
||||
t.Errorf("read of channel B = %q", got)
|
||||
}
|
||||
// 7 MHz is below 10, so SSB is lower sideband — a client told "ssb" would
|
||||
// not recognise it at all, the modulation list is the vocabulary.
|
||||
if got := ask(t, s, "modulation:0"); got != "modulation:0,lsb;" {
|
||||
t.Errorf("read of the mode = %q, want lsb below 10 MHz", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The client tunes the radio.
|
||||
func TestAClientCanTuneAndSetModeAndKey(t *testing.T) {
|
||||
r := &fakeRig{freq: 14074000, rxFreq: 14074000, mode: "USB"}
|
||||
s := srv(r)
|
||||
ask(t, s, "vfo:0,0,14200000")
|
||||
ask(t, s, "modulation:0,cw")
|
||||
ask(t, s, "trx:0,true")
|
||||
ask(t, s, "trx:0,false")
|
||||
want := []string{"freq=14200000", "mode=CW", "ptt=true", "ptt=false"}
|
||||
if strings.Join(r.calls, " ") != strings.Join(want, " ") {
|
||||
t.Errorf("the radio was told %v, want %v", r.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Channel B is the SPLIT transmit frequency. Writing it while the rig is
|
||||
// simplex must not move the only VFO there is: the client asked to prepare a
|
||||
// transmit frequency, not to QSY — and a logger that did this on every spot
|
||||
// click would drag the operator off the station they were listening to.
|
||||
func TestWritingChannelBWhileSimplexLeavesTheRigAlone(t *testing.T) {
|
||||
r := &fakeRig{freq: 14074000, rxFreq: 14074000, mode: "USB"}
|
||||
s := srv(r)
|
||||
ask(t, s, "vfo:0,1,14080000")
|
||||
if len(r.calls) != 0 {
|
||||
t.Errorf("the radio was told %v — a split TX frequency moved a simplex rig", r.calls)
|
||||
}
|
||||
// With split armed it means what it says.
|
||||
r.split, r.txHz = true, 14074000
|
||||
ask(t, s, "vfo:0,1,14080000")
|
||||
if len(r.calls) != 1 || r.calls[0] != "split=true,14080000" {
|
||||
t.Errorf("with split on the radio was told %v, want the new transmit frequency", r.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// A backend that cannot split says so, and the refusal must not be dressed up
|
||||
// as success: the client can then tell the operator to use Fake It, where
|
||||
// before it would transmit on the receive frequency believing all was well.
|
||||
func TestARefusedSplitIsNotAnnouncedAsDone(t *testing.T) {
|
||||
r := &fakeRig{freq: 14025000, rxFreq: 14025000, mode: "CW", splitErr: fmt.Errorf("this backend cannot split")}
|
||||
s := srv(r)
|
||||
if got := ask(t, s, "split_enable:0,true"); got != "" {
|
||||
t.Errorf("a refused split answered %q", got)
|
||||
}
|
||||
if r.split {
|
||||
t.Error("the rig was recorded as split after the backend refused")
|
||||
}
|
||||
}
|
||||
|
||||
// Only what moved is sent. TCI clients redraw on every command they receive, so
|
||||
// re-sending an unchanged frequency four times a second makes a VFO readout
|
||||
// flicker and, in some clients, fights the operator's own tuning.
|
||||
func TestOnlyChangesAreSent(t *testing.T) {
|
||||
r := &fakeRig{freq: 14074000, rxFreq: 14074000, mode: "USB"}
|
||||
s := srv(r)
|
||||
|
||||
first := s.publish()
|
||||
if len(first) == 0 {
|
||||
t.Fatal("the first pass sent nothing — a client would never learn the state")
|
||||
}
|
||||
if got := s.publish(); len(got) != 0 {
|
||||
t.Errorf("an unchanged radio produced %v", got)
|
||||
}
|
||||
|
||||
r.freq, r.rxFreq = 14200000, 14200000
|
||||
got := strings.Join(s.publish(), "")
|
||||
// Both channels move together on a simplex rig, and the transmit frequency
|
||||
// has its own command besides — a client showing "TX 14.200" reads that one,
|
||||
// and channel B alone leaves it stale.
|
||||
for _, want := range []string{"vfo:0,0,14200000;", "vfo:0,1,14200000;", "tx_frequency:14200000;"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("after a QSY the clients were not told %q — got %q", want, got)
|
||||
}
|
||||
}
|
||||
if got := s.publish(); len(got) != 0 {
|
||||
t.Errorf("the QSY was re-sent: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Modes travel both ways, and the data family is the interesting half: a client
|
||||
// switching to "digital" must not overwrite the mode the operator chose in
|
||||
// OpsLog with a guess at which data mode it was.
|
||||
func TestModeMapping(t *testing.T) {
|
||||
up := []struct {
|
||||
adif string
|
||||
hz int64
|
||||
want string
|
||||
}{
|
||||
{"CW", 14025000, "cw"},
|
||||
{"SSB", 14200000, "usb"},
|
||||
{"SSB", 7100000, "lsb"},
|
||||
{"USB", 7100000, "usb"}, // an explicit sideband is never second-guessed
|
||||
{"FT8", 14074000, "digu"},
|
||||
{"RTTY", 14080000, "digl"},
|
||||
{"AM", 3700000, "am"},
|
||||
{"FM", 145500000, "nfm"},
|
||||
{"", 14074000, ""},
|
||||
}
|
||||
for _, c := range up {
|
||||
if got := adifToTCIMode(c.adif, c.hz); got != c.want {
|
||||
t.Errorf("adifToTCIMode(%q, %d) = %q, want %q", c.adif, c.hz, got, c.want)
|
||||
}
|
||||
}
|
||||
down := map[string]string{
|
||||
"cw": "CW", "usb": "USB", "lsb": "LSB", "am": "AM", "sam": "AM",
|
||||
"nfm": "FM", "digu": "DATA", "digl": "DATA", "": "",
|
||||
}
|
||||
for in, want := range down {
|
||||
if got := tciModeToADIF(in); got != want {
|
||||
t.Errorf("tciModeToADIF(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A command for something OpsLog is not — audio streams, CW macros, the
|
||||
// panorama's settings — is met with silence rather than an error. A client
|
||||
// sends these hopefully at connect, and a refusal it did not ask for reads as a
|
||||
// fault with the rig.
|
||||
func TestUnknownCommandsAreQuiet(t *testing.T) {
|
||||
s := srv(&fakeRig{freq: 14074000, rxFreq: 14074000, mode: "USB"})
|
||||
for _, cmd := range []string{"audio_start:0", "cw_macros_speed:25", "rx_filter_band:0,-2700,-100", "iq_start:0"} {
|
||||
if got := ask(t, s, cmd); got != "" {
|
||||
t.Errorf("%q answered %q", cmd, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Split, with the client sending the two commands in the order it prefers.
|
||||
//
|
||||
// A client working split has to say two things: where to transmit, and that
|
||||
// split is on. Nothing obliges it to say them in that order, and the frequency
|
||||
// arriving first is the dangerous case: discarding it and then arming split
|
||||
// leaves the transmit VFO on whatever it held — the RECEIVE frequency — so the
|
||||
// operator transmits straight onto the DX while their software shows exactly
|
||||
// what they asked for.
|
||||
func TestSplitIsArmedOnTheFrequencyTheClientGaveWhicheverOrderItCame(t *testing.T) {
|
||||
// Frequency first, then split — the order that used to lose the frequency.
|
||||
r := &fakeRig{freq: 14025000, rxFreq: 14025000, mode: "CW"}
|
||||
s := srv(r)
|
||||
ask(t, s, "vfo:0,1,14027000")
|
||||
ask(t, s, "split_enable:0,true")
|
||||
if len(r.calls) != 1 || r.calls[0] != "split=true,14027000" {
|
||||
t.Errorf("frequency first: the radio was told %v, want split armed on 14027000", r.calls)
|
||||
}
|
||||
|
||||
// Split first, then the frequency — the order that always worked.
|
||||
r2 := &fakeRig{freq: 14025000, rxFreq: 14025000, mode: "CW"}
|
||||
s2 := srv(r2)
|
||||
ask(t, s2, "split_enable:0,true")
|
||||
ask(t, s2, "vfo:0,1,14027000")
|
||||
if len(r2.calls) == 0 || r2.calls[len(r2.calls)-1] != "split=true,14027000" {
|
||||
t.Errorf("split first: the radio was told %v, want it to end on 14027000", r2.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// "Fake It" uses no split at all: the client shifts the DIAL at the start of
|
||||
// transmit and shifts it back at the end. All it needs is channel A, and it
|
||||
// must reach the radio both ways.
|
||||
func TestFakeItIsJustTheDialMoving(t *testing.T) {
|
||||
r := &fakeRig{freq: 14074000, rxFreq: 14074000, mode: "USB"}
|
||||
s := srv(r)
|
||||
ask(t, s, "vfo:0,0,14075300") // up for the over
|
||||
ask(t, s, "vfo:0,0,14074000") // and back
|
||||
want := []string{"freq=14075300", "freq=14074000"}
|
||||
if strings.Join(r.calls, " ") != strings.Join(want, " ") {
|
||||
t.Errorf("the radio was told %v, want %v", r.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A client in Fake It still says "split off" to be sure. The rig is already
|
||||
// simplex, so there is nothing to do — and saying so beats asking a backend
|
||||
// that may not be able to set split at all.
|
||||
//
|
||||
// This is what broke JTDX through the rigctl server: an error answered to a
|
||||
// request that was already true, read as rig control failing, and the
|
||||
// transmission abandoned a second into the frame.
|
||||
func TestSayingSplitOffWhenAlreadySimplexTouchesNothing(t *testing.T) {
|
||||
r := &fakeRig{freq: 14074000, rxFreq: 14074000, mode: "USB",
|
||||
splitErr: fmt.Errorf("this backend cannot split")}
|
||||
s := srv(r)
|
||||
ask(t, s, "split_enable:0,false")
|
||||
if len(r.calls) != 0 {
|
||||
t.Errorf("the radio was told %v for a state it was already in", r.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// A client restating PTT must not re-command the radio. Through the rigctl
|
||||
// server, one sent set_ptt 0 sixteen times a second and the Flex's own transmit
|
||||
// request was overwritten between two of them inside a millisecond.
|
||||
func TestRepeatedPTTIsNotResentToTheRadio(t *testing.T) {
|
||||
r := &fakeRig{freq: 14074000, rxFreq: 14074000, mode: "USB"}
|
||||
s := srv(r)
|
||||
for i := 0; i < 5; i++ {
|
||||
ask(t, s, "trx:0,false")
|
||||
}
|
||||
if len(r.calls) != 1 || r.calls[0] != "ptt=false" {
|
||||
// The FIRST one always goes through: there is no knowing how the radio
|
||||
// was left.
|
||||
t.Errorf("the radio was told %v, want one unkey and no repeats", r.calls)
|
||||
}
|
||||
ask(t, s, "trx:0,true")
|
||||
ask(t, s, "trx:0,true")
|
||||
if len(r.calls) != 2 || r.calls[1] != "ptt=true" {
|
||||
t.Errorf("the radio was told %v, want the change through and the repeat dropped", r.calls)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A ClubLog exception has a validity window, and a DXpedition's window closes.
|
||||
// So a backdated entry must be enriched as of WHEN IT HAPPENED — 3Y0K typed
|
||||
// months after the activation, with the activation's date in the form, resolved
|
||||
// at today's date, found no live exception, and fell back to cty.dat:
|
||||
// Antarctica, where the log says Bouvet Island.
|
||||
//
|
||||
// The date is trusted to move the resolution BACKWARDS only. A half-typed date
|
||||
// must not send the lookup to the year 20, and a mistyped future one must not
|
||||
// resolve against a window that has not opened.
|
||||
func TestLookupWhen(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
|
||||
if got := lookupWhen("2026-03-08"); got.Format("2006-01-02") != "2026-03-08" {
|
||||
t.Errorf("a past date gave %v — the activation's own date is the whole point", got)
|
||||
}
|
||||
// Midday, not midnight: a window given in whole days is inclusive of its end
|
||||
// date, and 00:00 sits exactly on the boundary.
|
||||
if h := lookupWhen("2026-03-08").Hour(); h != 12 {
|
||||
t.Errorf("resolved at %02d:00, want 12:00 — midnight sits on the window boundary", h)
|
||||
}
|
||||
|
||||
for name, in := range map[string]string{
|
||||
"empty": "",
|
||||
"spaces": " ",
|
||||
"half-typed": "2026-0",
|
||||
"not a date": "hier",
|
||||
"wrong format": "08/03/2026",
|
||||
} {
|
||||
if got := lookupWhen(in); got.Before(now.Add(-time.Minute)) {
|
||||
t.Errorf("%s (%q) resolved to %v — anything unusable must mean now", name, in, got)
|
||||
}
|
||||
}
|
||||
|
||||
future := now.AddDate(1, 0, 0).Format("2006-01-02")
|
||||
if got := lookupWhen(future); got.After(now.Add(time.Minute)) {
|
||||
t.Errorf("a future date (%s) resolved to %v — a mistyped year must not open a window early", future, got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Every setting in OpsLog is per profile, station hardware included. A device
|
||||
// started at boot and never again therefore stays on the PREVIOUS profile's
|
||||
// port until Settings is opened and saved — which is how an operator running an
|
||||
// SPE on COM9 for HF and another on COM10 for 6 m, one per profile, found the
|
||||
// amplifier still on the old port after switching. Save is not a connect
|
||||
// button.
|
||||
//
|
||||
// This test keeps startup and reloadAfterProfileSwitch in lockstep: anything
|
||||
// started at boot must either be re-applied on a profile switch or be listed
|
||||
// below with the reason it must not be. Adding a device makes the choice
|
||||
// explicit instead of leaving the fifth one to be found by a user.
|
||||
func TestProfileSwitchReappliesEveryStartupDevice(t *testing.T) {
|
||||
src, err := os.ReadFile("app.go")
|
||||
if err != nil {
|
||||
t.Fatalf("read app.go: %v", err)
|
||||
}
|
||||
|
||||
// Started at boot but deliberately NOT re-run on a profile switch:
|
||||
notPerProfile := map[string]string{
|
||||
"startAllEnabledClusters": "the cluster panel reconnects itself; its servers are a global list",
|
||||
"startGridCache": "a shared on-disk grid cache, not a profile's",
|
||||
"startBandOpenFeed": "PSK Reporter, keyed on the operator grid it re-reads itself",
|
||||
}
|
||||
|
||||
startup := body(t, string(src), "func (a *App) startup(ctx context.Context) {")
|
||||
reload := body(t, string(src), "func (a *App) reloadAfterProfileSwitch() {")
|
||||
|
||||
call := regexp.MustCompile(`a\.(start[A-Z][A-Za-z]*)\b`)
|
||||
seen := map[string]bool{}
|
||||
for _, m := range call.FindAllStringSubmatch(startup, -1) {
|
||||
name := m[1]
|
||||
if seen[name] || notPerProfile[name] != "" {
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
if !strings.Contains(reload, "a."+name) {
|
||||
t.Errorf("%s runs at startup but not on a profile switch — the device stays on the previous profile's settings.\n"+
|
||||
"Add it to reloadAfterProfileSwitch, or to notPerProfile here with the reason it must not follow the profile.", name)
|
||||
}
|
||||
}
|
||||
if len(seen) == 0 {
|
||||
t.Fatal("no startup device starters found — this test has stopped checking anything")
|
||||
}
|
||||
}
|
||||
|
||||
// body returns the source of the function opening with the given signature,
|
||||
// up to the closing brace in column 0.
|
||||
func body(t *testing.T, src, signature string) string {
|
||||
t.Helper()
|
||||
i := strings.Index(src, signature)
|
||||
if i < 0 {
|
||||
t.Fatalf("%q not found in app.go", signature)
|
||||
}
|
||||
rest := src[i+len(signature):]
|
||||
if j := strings.Index(rest, "\n}"); j >= 0 {
|
||||
return rest[:j]
|
||||
}
|
||||
return rest
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The default QSL e-mail closes with a credit line carrying the download page.
|
||||
//
|
||||
// It is part of the BODY TEMPLATE, deliberately: an operator who does not want
|
||||
// it deletes it once, and one who already wrote their own body never sees it,
|
||||
// because a stored template is returned verbatim and the default is only the
|
||||
// fallback. Appending it at send time would have made it unremovable.
|
||||
func TestQSLCreditIsInTheDefaultBody(t *testing.T) {
|
||||
if !strings.HasSuffix(defaultQSLEmailBody, qslCredit) {
|
||||
t.Errorf("the default QSL e-mail does not end with the credit line:\n%q", defaultQSLEmailBody)
|
||||
}
|
||||
// The body still has to be a usable template — the credit is added to it,
|
||||
// not in place of it.
|
||||
for _, v := range []string{"{DATE}", "{BAND}", "{MODE}", "{MYCALL}"} {
|
||||
if !strings.Contains(defaultQSLEmailBody, v) {
|
||||
t.Errorf("the default QSL e-mail lost %s", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The link must be the page a person can read. updateCheckURL sits beside it and
|
||||
// answers JSON — sending a correspondent there is the easy mistake to make.
|
||||
func TestQSLCreditLinksTheHumanPage(t *testing.T) {
|
||||
if !strings.Contains(qslCredit, releasesPageURL) {
|
||||
t.Errorf("credit %q does not carry the releases page", qslCredit)
|
||||
}
|
||||
if strings.Contains(qslCredit, "api.github.com") {
|
||||
t.Errorf("credit %q points at the update API, which answers JSON to whoever clicks it", qslCredit)
|
||||
}
|
||||
if !strings.Contains(qslCredit, "OpsLog") {
|
||||
t.Errorf("credit %q does not name OpsLog", qslCredit)
|
||||
}
|
||||
}
|
||||
@@ -203,3 +203,34 @@ func (a *App) applyRelayAuto(freqHz int64, band string) {
|
||||
wruntime.EventsEmit(a.ctx, "station:relay_auto", nil) // nudge the Station Control UI to re-poll
|
||||
}
|
||||
}
|
||||
|
||||
// EntryBandChanged drives the band-following features from the QSO ENTRY band
|
||||
// selector, for a station whose rig OpsLog does not control.
|
||||
//
|
||||
// Both of them — relay automatic control and the band-change outbound rows —
|
||||
// hang off the rig state, and that is right when there IS a rig: changing Band
|
||||
// in the entry strip pushes a QSY to it, the new state comes back through the
|
||||
// CAT callback, and the relays follow from there. Without a CAT connection
|
||||
// nothing is pushed and nothing comes back, so an operator with an antenna
|
||||
// switch and no rig control changed band in OpsLog and watched the switch sit
|
||||
// exactly where it was. Reported as automatic control not working; it was never
|
||||
// told the band had changed.
|
||||
//
|
||||
// The frontend calls this ONLY when the CAT push did not happen, and never when
|
||||
// the band or frequency lock is on — a lock means the entry is deliberately
|
||||
// decoupled from the rig (logging an old contact off-frequency), and moving an
|
||||
// antenna to match a QSO from last year is worse than doing nothing.
|
||||
//
|
||||
// The frequency is passed as unknown, deliberately: a band selector gives a
|
||||
// band and nothing else, and rules written on a frequency RANGE must be left
|
||||
// alone rather than evaluated against a made-up dial reading.
|
||||
func (a *App) EntryBandChanged(band string) {
|
||||
band = strings.TrimSpace(band)
|
||||
if band == "" {
|
||||
return
|
||||
}
|
||||
if a.relayAutoOn.Load() {
|
||||
go a.applyRelayAuto(0, band)
|
||||
}
|
||||
a.emitBandChangeTrigger(band, "", 0)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The generic HTTP board must actually be built from its URLs.
|
||||
//
|
||||
// It was not: buildDeviceDriver had no case for it, so it fell through to the
|
||||
// WebSwitch driver. The board was configured, saved, listed — and every command
|
||||
// went to a WebSwitch address that did not exist, which also left the device
|
||||
// reported as offline and every relay button on the panel greyed out. Nothing in
|
||||
// the UI said so; the URLs were simply never sent.
|
||||
func TestGenericHTTPBoardSendsItsConfiguredURL(t *testing.T) {
|
||||
hit := make(chan string, 4)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hit <- r.URL.Path
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// Host deliberately empty — this board's whole address is in the URLs.
|
||||
d := StationDevice{
|
||||
Type: "httpgen",
|
||||
Channels: 2,
|
||||
OnURLs: []string{srv.URL + "/relay1/on", srv.URL + "/relay2/on"},
|
||||
OffURLs: []string{srv.URL + "/relay1/off", srv.URL + "/relay2/off"},
|
||||
}
|
||||
if err := buildDeviceDriver(d).Set(context.Background(), 2, true); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
select {
|
||||
case got := <-hit:
|
||||
if got != "/relay2/on" {
|
||||
t.Errorf("board was asked for %q, want /relay2/on", got)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("the configured URL was never requested — the board is not using its own driver")
|
||||
}
|
||||
}
|
||||
|
||||
// Editing a URL must rebuild the driver. The cached one is keyed by the device's
|
||||
// configuration, and the URLs used not to be part of that key: correcting a typo
|
||||
// handed back the driver still holding the old address, so the fix looked like it
|
||||
// had done nothing until OpsLog was restarted.
|
||||
func TestGenericHTTPBoardKeyCoversItsURLs(t *testing.T) {
|
||||
a := StationDevice{Type: "httpgen", Channels: 2, OnURLs: []string{"http://box/a"}}
|
||||
b := StationDevice{Type: "httpgen", Channels: 2, OnURLs: []string{"http://box/b"}}
|
||||
if deviceKey(a) == deviceKey(b) {
|
||||
t.Error("two boards with different URLs share a cache key — an edited URL would not take effect")
|
||||
}
|
||||
c := a
|
||||
c.OnPat = "http://box/{relay}"
|
||||
if deviceKey(a) == deviceKey(c) {
|
||||
t.Error("changing the ON pattern left the cache key unchanged")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hamlog/internal/db"
|
||||
"hamlog/internal/qso"
|
||||
"hamlog/internal/syncfolder"
|
||||
)
|
||||
|
||||
// syncTestApp is an App with nothing but a logbook: applySyncRecord touches the
|
||||
// repository and the log file, and no more. Settings are nil, which is exactly
|
||||
// the state it must survive anyway — the loop runs before the active profile is
|
||||
// known.
|
||||
func syncTestApp(t *testing.T) *App {
|
||||
t.Helper()
|
||||
conn, err := db.Open(filepath.Join(t.TempDir(), "log.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { conn.Close() })
|
||||
return &App{ctx: context.Background(), qso: qso.NewRepo(conn)}
|
||||
}
|
||||
|
||||
func syncRecord(t *testing.T, op syncfolder.Op, uid string, q qso.QSO) syncfolder.Record {
|
||||
t.Helper()
|
||||
rec := syncfolder.Record{V: syncfolder.FormatVersion, Op: op, UID: uid, At: time.Now().UTC(), By: "other"}
|
||||
if op != syncfolder.OpDelete {
|
||||
b, err := json.Marshal(q)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
rec.Data = b
|
||||
}
|
||||
return rec
|
||||
}
|
||||
|
||||
func countQSOs(t *testing.T, a *App) int64 {
|
||||
t.Helper()
|
||||
n, err := a.qso.Count(a.ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// The ordinary life of a contact made on the other PC: it arrives, it is
|
||||
// corrected, it is deleted. One row throughout — a sync that inserted a second
|
||||
// copy on the edit would be worse than no sync at all.
|
||||
func TestSyncRecordAddThenUpdateThenDelete(t *testing.T) {
|
||||
a := syncTestApp(t)
|
||||
when := time.Date(2026, 8, 16, 14, 32, 0, 0, time.UTC)
|
||||
uid := syncfolder.NewUID()
|
||||
|
||||
if !a.applySyncRecord(syncRecord(t, syncfolder.OpAdd, uid, qso.QSO{
|
||||
Callsign: "M0ABC", QSODate: when, Band: "20m", Mode: "CW", Name: "Ann",
|
||||
})) {
|
||||
t.Fatal("the added contact was not applied")
|
||||
}
|
||||
if n := countQSOs(t, a); n != 1 {
|
||||
t.Fatalf("log holds %d QSOs after an add, want 1", n)
|
||||
}
|
||||
// Stamped with the identity from the record — without this every later
|
||||
// change naming it would look like a contact never seen before.
|
||||
id, found, err := a.qso.IDBySyncUID(a.ctx, uid)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("IDBySyncUID = (%d,%v,%v), want the new row", id, found, err)
|
||||
}
|
||||
|
||||
if !a.applySyncRecord(syncRecord(t, syncfolder.OpUpdate, uid, qso.QSO{
|
||||
Callsign: "M0ABC", QSODate: when, Band: "20m", Mode: "CW", Name: "Annette",
|
||||
})) {
|
||||
t.Fatal("the correction was not applied")
|
||||
}
|
||||
if n := countQSOs(t, a); n != 1 {
|
||||
t.Fatalf("log holds %d QSOs after an edit, want 1 — the edit was logged as a second contact", n)
|
||||
}
|
||||
got, err := a.qso.GetByID(a.ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if got.Name != "Annette" {
|
||||
t.Errorf("name = %q after the correction, want %q", got.Name, "Annette")
|
||||
}
|
||||
|
||||
if !a.applySyncRecord(syncRecord(t, syncfolder.OpDelete, uid, qso.QSO{})) {
|
||||
t.Fatal("the tombstone was not applied")
|
||||
}
|
||||
if n := countQSOs(t, a); n != 0 {
|
||||
t.Fatalf("log holds %d QSOs after the deletion, want 0", n)
|
||||
}
|
||||
// A tombstone that arrives twice — both peers relayed it, or the file was
|
||||
// re-read after a restore — must be quiet, not an error and not a change.
|
||||
if a.applySyncRecord(syncRecord(t, syncfolder.OpDelete, uid, qso.QSO{})) {
|
||||
t.Error("a repeated tombstone reported a change; the grid would refresh for nothing, for ever")
|
||||
}
|
||||
}
|
||||
|
||||
// The case the whole no-backfill decision rests on.
|
||||
//
|
||||
// Both PCs already hold the operator's 123 000 contacts — seeded from one
|
||||
// database or one ADIF — and neither row carries an identity, because nothing
|
||||
// has touched them since. The day the shack PC corrects a 2019 QSO it stamps an
|
||||
// identity and sends an update naming it; the laptop has never seen that
|
||||
// identity. Inserting would give the operator two copies of a contact they
|
||||
// merely corrected, and would do it for every edit for ever.
|
||||
func TestSyncRecordAdoptsTheContactAlreadyInTheLog(t *testing.T) {
|
||||
a := syncTestApp(t)
|
||||
when := time.Date(2019, 3, 2, 9, 15, 0, 0, time.UTC)
|
||||
|
||||
// The copy that was already here, with no identity.
|
||||
localID, err := a.qso.Add(a.ctx, qso.QSO{
|
||||
Callsign: "M0ABC", QSODate: when, Band: "40m", Mode: "SSB", Name: "Ann",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
uid := syncfolder.NewUID() // minted on the OTHER machine
|
||||
if !a.applySyncRecord(syncRecord(t, syncfolder.OpUpdate, uid, qso.QSO{
|
||||
Callsign: "M0ABC", QSODate: when, Band: "40m", Mode: "SSB", Name: "Annette", QTH: "Bristol",
|
||||
})) {
|
||||
t.Fatal("the correction was not applied")
|
||||
}
|
||||
if n := countQSOs(t, a); n != 1 {
|
||||
t.Fatalf("log holds %d QSOs, want 1 — the contact was duplicated instead of recognised", n)
|
||||
}
|
||||
got, err := a.qso.GetByID(a.ctx, localID)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if got.Name != "Annette" || got.QTH != "Bristol" {
|
||||
t.Errorf("the row already here was not corrected: name=%q qth=%q", got.Name, got.QTH)
|
||||
}
|
||||
// And it now carries the identity, so the NEXT change goes straight to it
|
||||
// without needing the contact-matching fallback again.
|
||||
if id, found, _ := a.qso.IDBySyncUID(a.ctx, uid); !found || id != localID {
|
||||
t.Errorf("IDBySyncUID = (%d,%v), want the row already here (%d)", id, found, localID)
|
||||
}
|
||||
}
|
||||
|
||||
// A different contact must NOT be adopted. The matching is deliberately narrow
|
||||
// — same callsign, same minute, same band, same mode — and this pins that a
|
||||
// second contact with the same station on another band stays a second contact.
|
||||
func TestSyncRecordDoesNotAdoptADifferentContact(t *testing.T) {
|
||||
a := syncTestApp(t)
|
||||
when := time.Date(2026, 8, 16, 14, 32, 0, 0, time.UTC)
|
||||
if _, err := a.qso.Add(a.ctx, qso.QSO{Callsign: "M0ABC", QSODate: when, Band: "40m", Mode: "CW"}); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if !a.applySyncRecord(syncRecord(t, syncfolder.OpAdd, syncfolder.NewUID(), qso.QSO{
|
||||
Callsign: "M0ABC", QSODate: when, Band: "20m", Mode: "CW",
|
||||
})) {
|
||||
t.Fatal("the contact was not applied")
|
||||
}
|
||||
if n := countQSOs(t, a); n != 2 {
|
||||
t.Fatalf("log holds %d QSOs, want 2 — a contact on another band was swallowed as a duplicate", n)
|
||||
}
|
||||
}
|
||||
|
||||
// A record with no callsign is not a contact. It reaches here from a file
|
||||
// truncated by a sync client mid-upload, or from a future format read
|
||||
// optimistically, and inserting it would put a blank row in the log.
|
||||
func TestSyncRecordIgnoresAContactWithNoCallsign(t *testing.T) {
|
||||
a := syncTestApp(t)
|
||||
if a.applySyncRecord(syncRecord(t, syncfolder.OpAdd, syncfolder.NewUID(), qso.QSO{Band: "20m", Mode: "CW"})) {
|
||||
t.Error("a record with no callsign was applied")
|
||||
}
|
||||
if n := countQSOs(t, a); n != 0 {
|
||||
t.Fatalf("log holds %d QSOs, want 0", n)
|
||||
}
|
||||
}
|
||||
+554
@@ -0,0 +1,554 @@
|
||||
package main
|
||||
|
||||
// Folder synchronisation — one operator, several PCs, one logbook.
|
||||
//
|
||||
// The operator points every OpsLog at the SAME folder (Seafile, OneDrive,
|
||||
// Dropbox, a NAS share). Each machine appends what it logs, edits and deletes
|
||||
// to its own file in there, and reads the others'. internal/syncfolder holds
|
||||
// the format and the merge rules, and its package doc explains why the change
|
||||
// log is a set of append-only files rather than the database itself.
|
||||
//
|
||||
// This file is the wiring: settings, the loop, and the three hooks on the
|
||||
// logging path.
|
||||
//
|
||||
// WHAT SYNCHRONISES. Only what happens from the moment it is switched on.
|
||||
// There is deliberately no mass backfill of the log already on disk: the two
|
||||
// PCs of an operator who has been logging for years hold the same history
|
||||
// already (one was seeded from the other, or from the same ADIF), and pushing
|
||||
// 123 000 contacts through a synced folder to tell the other machine what it
|
||||
// already knows would cost hours and gain nothing. A contact is stamped with an
|
||||
// identity when it is touched — logged, edited, deleted — and that is what the
|
||||
// other machines are told about.
|
||||
//
|
||||
// WHY IT STILL RECOGNISES OLD CONTACTS. Because an edit to a 2019 QSO does
|
||||
// travel, and the receiving machine has that QSO under a different row id and
|
||||
// no identity. It matches on the contact itself (callsign, minute, band, mode)
|
||||
// before inserting, so an edit lands on the row already there instead of
|
||||
// creating a second copy. That is IDByDedupeKey, and it is the whole reason the
|
||||
// no-backfill decision is safe.
|
||||
//
|
||||
// NOT LIVE, AND NOT MEANT TO BE. Two operators working a contest together want
|
||||
// the shared MySQL logbook, which OpsLog already does. This is for one operator
|
||||
// whose contacts are spread across a shack PC, a laptop and a portable rig.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
"hamlog/internal/qso"
|
||||
"hamlog/internal/syncfolder"
|
||||
)
|
||||
|
||||
// Settings keys. All PROFILE-SCOPED, and that is load-bearing: each profile can
|
||||
// point at its own logbook, so each needs its own folder, its own machine id
|
||||
// (hence its own file — two profiles sharing a folder would otherwise write
|
||||
// two logbooks into one) and its own read positions.
|
||||
const (
|
||||
keySyncFolder = "syncfolder.config"
|
||||
keySyncFolderMachine = "syncfolder.machine" // this installation's id, minted once
|
||||
keySyncFolderOffsets = "syncfolder.offsets" // peer machine id → bytes already read
|
||||
keySyncFolderSeq = "syncfolder.seq" // this machine's own counter
|
||||
)
|
||||
|
||||
// syncPollInterval is how often the folder is examined. A synced folder is not
|
||||
// instant anyway — Seafile and OneDrive take seconds to notice a change and
|
||||
// seconds more to push it — so polling faster would only burn a directory
|
||||
// listing to learn nothing.
|
||||
const syncPollInterval = 20 * time.Second
|
||||
|
||||
// FolderSyncConfig is what the operator sets.
|
||||
type FolderSyncConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Folder string `json:"folder"`
|
||||
// Machine is the operator's own name for this PC — "shack", "portable".
|
||||
// It only labels the file and the status; the identity that matters is the
|
||||
// id minted from it, which carries a random suffix so two PCs both called
|
||||
// "shack" still never write to one file.
|
||||
Machine string `json:"machine"`
|
||||
}
|
||||
|
||||
// FolderSyncPeer is another machine seen in the folder.
|
||||
type FolderSyncPeer struct {
|
||||
Machine string `json:"machine"`
|
||||
// LastChange is the file's modification time — "when did that PC last log
|
||||
// anything", which is the question an operator actually asks of this list.
|
||||
LastChange string `json:"last_change"`
|
||||
Behind int64 `json:"behind"` // bytes written but not yet read here
|
||||
}
|
||||
|
||||
// FolderSyncStatus is what the settings panel shows.
|
||||
type FolderSyncStatus struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Folder string `json:"folder"`
|
||||
MachineID string `json:"machine_id"`
|
||||
Peers []FolderSyncPeer `json:"peers"`
|
||||
LastSync string `json:"last_sync"`
|
||||
Sent int64 `json:"sent"`
|
||||
Received int64 `json:"received"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func (a *App) loadFolderSync() FolderSyncConfig {
|
||||
var cfg FolderSyncConfig
|
||||
if a.settings == nil || !a.settingsScoped.Load() {
|
||||
return cfg
|
||||
}
|
||||
s, _ := a.settings.Get(a.ctx, keySyncFolder)
|
||||
if strings.TrimSpace(s) != "" {
|
||||
_ = json.Unmarshal([]byte(s), &cfg)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// GetFolderSync returns the configuration for the settings panel.
|
||||
func (a *App) GetFolderSync() FolderSyncConfig {
|
||||
a.syncMu.Lock()
|
||||
defer a.syncMu.Unlock()
|
||||
return a.loadFolderSync()
|
||||
}
|
||||
|
||||
// SaveFolderSync persists the configuration.
|
||||
//
|
||||
// The folder is checked by WRITING to it, not by asking whether it exists: a
|
||||
// cloud folder that is read-only, or a NAS share whose credentials have
|
||||
// expired, exists perfectly well and would swallow every contact in silence.
|
||||
// Better to refuse in the settings panel, where the operator is looking.
|
||||
func (a *App) SaveFolderSync(cfg FolderSyncConfig) error {
|
||||
a.syncMu.Lock()
|
||||
defer a.syncMu.Unlock()
|
||||
cfg.Folder = strings.TrimSpace(cfg.Folder)
|
||||
cfg.Machine = strings.TrimSpace(cfg.Machine)
|
||||
if cfg.Enabled {
|
||||
if cfg.Folder == "" {
|
||||
return fmt.Errorf("choose the synchronised folder first")
|
||||
}
|
||||
if err := checkWritableDir(cfg.Folder); err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.Machine == "" {
|
||||
cfg.Machine = "PC"
|
||||
}
|
||||
}
|
||||
// The id is minted from the name ONCE and then kept, even if the operator
|
||||
// renames the PC afterwards. Re-minting would orphan the file already in
|
||||
// the folder: the other machines would go on reading the old one for ever
|
||||
// and never see another contact from here.
|
||||
if cfg.Enabled && a.settings != nil {
|
||||
if cur, _ := a.settings.Get(a.ctx, keySyncFolderMachine); strings.TrimSpace(cur) == "" {
|
||||
a.setSetting(keySyncFolderMachine, syncfolder.NewMachineID(cfg.Machine))
|
||||
}
|
||||
}
|
||||
b, _ := json.Marshal(cfg)
|
||||
a.setSetting(keySyncFolder, string(b))
|
||||
applog.Printf("foldersync: enabled=%v folder=%q machine=%q", cfg.Enabled, cfg.Folder, cfg.Machine)
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkWritableDir proves the folder can be written to, and cleans up after
|
||||
// itself.
|
||||
func checkWritableDir(dir string) error {
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot reach %s: %w", dir, err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("%s is not a folder", dir)
|
||||
}
|
||||
probe := filepath.Join(dir, ".opslog-write-test")
|
||||
if err := os.WriteFile(probe, []byte("opslog"), 0o644); err != nil {
|
||||
return fmt.Errorf("cannot write to %s: %w", dir, err)
|
||||
}
|
||||
_ = os.Remove(probe)
|
||||
return nil
|
||||
}
|
||||
|
||||
// PickFolderSyncFolder opens the folder chooser.
|
||||
func (a *App) PickFolderSyncFolder() (string, error) {
|
||||
if a.ctx == nil {
|
||||
return "", fmt.Errorf("no app context")
|
||||
}
|
||||
return wruntime.OpenDirectoryDialog(a.ctx, wruntime.OpenDialogOptions{
|
||||
Title: "Choose the folder your PCs already synchronise",
|
||||
})
|
||||
}
|
||||
|
||||
// syncStore returns this machine's view of the folder, or nil when folder
|
||||
// synchronisation is off or not configured. Every caller treats nil as "not
|
||||
// our business" — the hooks on the logging path especially, where this must
|
||||
// cost nothing at all for the operators who never turn it on.
|
||||
func (a *App) syncStore() (*syncfolder.Store, FolderSyncConfig) {
|
||||
cfg := a.loadFolderSync()
|
||||
if !cfg.Enabled || cfg.Folder == "" || a.settings == nil {
|
||||
return nil, cfg
|
||||
}
|
||||
id, _ := a.settings.Get(a.ctx, keySyncFolderMachine)
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return nil, cfg
|
||||
}
|
||||
return syncfolder.New(cfg.Folder, id), cfg
|
||||
}
|
||||
|
||||
// nextSyncSeq hands out this machine's next counter value.
|
||||
//
|
||||
// Persisted on every use rather than at shutdown: the counter breaks ties
|
||||
// between two changes made in the same second, and one that restarted at zero
|
||||
// after a crash would make an older change beat a newer one for ever.
|
||||
func (a *App) nextSyncSeq() uint64 {
|
||||
n := uint64(0)
|
||||
if a.settings != nil {
|
||||
s, _ := a.settings.Get(a.ctx, keySyncFolderSeq)
|
||||
fmt.Sscanf(strings.TrimSpace(s), "%d", &n)
|
||||
}
|
||||
n++
|
||||
a.setSetting(keySyncFolderSeq, fmt.Sprintf("%d", n))
|
||||
return n
|
||||
}
|
||||
|
||||
// syncUIDFor returns a contact's identity, minting and stamping one if it has
|
||||
// none. This is where an old QSO joins the sync: not in bulk, but the first
|
||||
// time it is touched.
|
||||
func (a *App) syncUIDFor(id int64, known string) string {
|
||||
if strings.TrimSpace(known) != "" {
|
||||
return known
|
||||
}
|
||||
if a.qso == nil || id <= 0 {
|
||||
return ""
|
||||
}
|
||||
if q, err := a.qso.GetByID(a.ctx, id); err == nil && strings.TrimSpace(q.SyncUID) != "" {
|
||||
return q.SyncUID
|
||||
}
|
||||
uid := syncfolder.NewUID()
|
||||
if err := a.qso.SetSyncUID(a.ctx, id, uid); err != nil {
|
||||
applog.Printf("foldersync: stamping QSO %d failed: %v", id, err)
|
||||
return ""
|
||||
}
|
||||
return uid
|
||||
}
|
||||
|
||||
// syncPublish records one local change for the other machines.
|
||||
//
|
||||
// Never on the critical path of logging: a folder on a network share can block
|
||||
// for seconds, and a contact must be in the database and on screen long before
|
||||
// anyone cares that another PC knows about it. Callers run it in a goroutine.
|
||||
func (a *App) syncPublish(op syncfolder.Op, id int64, q *qso.QSO) {
|
||||
a.syncMu.Lock()
|
||||
defer a.syncMu.Unlock()
|
||||
store, _ := a.syncStore()
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
known := ""
|
||||
if q != nil {
|
||||
known = q.SyncUID
|
||||
}
|
||||
uid := a.syncUIDFor(id, known)
|
||||
if uid == "" {
|
||||
return
|
||||
}
|
||||
rec := syncfolder.Record{Op: op, UID: uid, Seq: a.nextSyncSeq()}
|
||||
// A deletion carries no contact — the tombstone is the whole message, and
|
||||
// the receiving machine finds the row by the identity.
|
||||
if op != syncfolder.OpDelete {
|
||||
full := q
|
||||
if full == nil || full.ID != id {
|
||||
got, err := a.qso.GetByID(a.ctx, id)
|
||||
if err != nil {
|
||||
applog.Printf("foldersync: reading QSO %d back failed: %v", id, err)
|
||||
return
|
||||
}
|
||||
full = &got
|
||||
}
|
||||
// The row id is this machine's and means nothing anywhere else. Left in,
|
||||
// it would be read back as "update local row 4711" on a PC where 4711 is
|
||||
// somebody else entirely.
|
||||
cp := *full
|
||||
cp.ID = 0
|
||||
cp.SyncUID = uid
|
||||
b, err := json.Marshal(cp)
|
||||
if err != nil {
|
||||
applog.Printf("foldersync: encoding QSO %d failed: %v", id, err)
|
||||
return
|
||||
}
|
||||
rec.Data = b
|
||||
}
|
||||
if err := store.Append(rec); err != nil {
|
||||
a.syncErr = err.Error()
|
||||
applog.Printf("foldersync: append failed: %v", err)
|
||||
return
|
||||
}
|
||||
a.syncErr = ""
|
||||
a.syncSent++
|
||||
}
|
||||
|
||||
// syncPublishAsync is what the logging path calls.
|
||||
func (a *App) syncPublishAsync(op syncfolder.Op, id int64, q *qso.QSO) {
|
||||
if a.qso == nil {
|
||||
return
|
||||
}
|
||||
var cp *qso.QSO
|
||||
if q != nil {
|
||||
c := *q
|
||||
cp = &c
|
||||
}
|
||||
go a.syncPublish(op, id, cp)
|
||||
}
|
||||
|
||||
// syncPublishDeletes records tombstones for rows about to be deleted.
|
||||
//
|
||||
// Called BEFORE the delete and synchronously, for the same reason
|
||||
// deleteRemoteCopies is: once the rows are gone their identities are gone with
|
||||
// them, and a tombstone naming nothing tells the other machines nothing.
|
||||
func (a *App) syncPublishDeletes(ids []int64) {
|
||||
if a.qso == nil || len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
a.syncMu.Lock()
|
||||
store, _ := a.syncStore()
|
||||
a.syncMu.Unlock()
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
for _, id := range ids {
|
||||
q, err := a.qso.GetByID(a.ctx, id)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// A contact never touched since the sync was switched on has no identity,
|
||||
// and giving it one now is what makes the deletion addressable at all.
|
||||
a.syncMu.Lock()
|
||||
uid := a.syncUIDFor(id, q.SyncUID)
|
||||
if uid != "" {
|
||||
if err := store.Append(syncfolder.Record{Op: syncfolder.OpDelete, UID: uid, Seq: a.nextSyncSeq()}); err != nil {
|
||||
applog.Printf("foldersync: tombstone for QSO %d failed: %v", id, err)
|
||||
} else {
|
||||
a.syncSent++
|
||||
}
|
||||
}
|
||||
a.syncMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) loadSyncOffsets() map[string]int64 {
|
||||
out := map[string]int64{}
|
||||
if a.settings == nil {
|
||||
return out
|
||||
}
|
||||
s, _ := a.settings.Get(a.ctx, keySyncFolderOffsets)
|
||||
if strings.TrimSpace(s) != "" {
|
||||
_ = json.Unmarshal([]byte(s), &out)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *App) saveSyncOffsets(m map[string]int64) {
|
||||
b, _ := json.Marshal(m)
|
||||
a.setSetting(keySyncFolderOffsets, string(b))
|
||||
}
|
||||
|
||||
// folderSyncLoop reads the other machines' files on an interval, for the life
|
||||
// of the app. Cheap when switched off: one settings read.
|
||||
func (a *App) folderSyncLoop() {
|
||||
tick := time.NewTicker(syncPollInterval)
|
||||
defer tick.Stop()
|
||||
for range tick.C {
|
||||
if a.ctx == nil || a.qso == nil {
|
||||
continue
|
||||
}
|
||||
if n, err := a.folderSyncPass(); err != nil {
|
||||
applog.Printf("foldersync: %v", err)
|
||||
} else if n > 0 {
|
||||
applog.Printf("foldersync: applied %d change(s) from the folder", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SyncFolderNow runs one pass immediately — the "Synchronise now" button, and
|
||||
// what makes a first setup verifiable without waiting for the timer.
|
||||
func (a *App) SyncFolderNow() (int, error) {
|
||||
return a.folderSyncPass()
|
||||
}
|
||||
|
||||
// folderSyncPass reads every peer's new records once and applies the winners.
|
||||
func (a *App) folderSyncPass() (int, error) {
|
||||
a.syncMu.Lock()
|
||||
store, _ := a.syncStore()
|
||||
a.syncMu.Unlock()
|
||||
if store == nil || a.qso == nil {
|
||||
return 0, nil
|
||||
}
|
||||
peers, err := store.Peers()
|
||||
if err != nil {
|
||||
a.syncMu.Lock()
|
||||
a.syncErr = err.Error()
|
||||
a.syncMu.Unlock()
|
||||
return 0, err
|
||||
}
|
||||
offsets := a.loadSyncOffsets()
|
||||
var batch []syncfolder.Record
|
||||
advanced := map[string]int64{}
|
||||
for _, p := range peers {
|
||||
recs, next, err := syncfolder.ReadFrom(p.Path, offsets[p.MachineID])
|
||||
if err != nil {
|
||||
// One unreadable peer — a file mid-upload, a share that dropped —
|
||||
// must not stop the others. Its offset is left where it was, so
|
||||
// nothing is skipped when it comes back.
|
||||
applog.Printf("foldersync: reading %s: %v", p.MachineID, err)
|
||||
continue
|
||||
}
|
||||
batch = append(batch, recs...)
|
||||
advanced[p.MachineID] = next
|
||||
}
|
||||
if len(batch) == 0 {
|
||||
a.syncMu.Lock()
|
||||
a.syncLast = time.Now()
|
||||
a.syncErr = ""
|
||||
a.syncMu.Unlock()
|
||||
for id, off := range advanced {
|
||||
offsets[id] = off
|
||||
}
|
||||
a.saveSyncOffsets(offsets)
|
||||
return 0, nil
|
||||
}
|
||||
applied := 0
|
||||
for _, rec := range syncfolder.Merge(batch) {
|
||||
if a.applySyncRecord(rec) {
|
||||
applied++
|
||||
}
|
||||
}
|
||||
// Offsets advance only after the batch has been applied. Saved first, a
|
||||
// crash in between would lose those changes permanently — the records would
|
||||
// never be read again.
|
||||
for id, off := range advanced {
|
||||
offsets[id] = off
|
||||
}
|
||||
a.saveSyncOffsets(offsets)
|
||||
|
||||
a.syncMu.Lock()
|
||||
a.syncLast = time.Now()
|
||||
a.syncReceived += int64(applied)
|
||||
a.syncErr = ""
|
||||
a.syncMu.Unlock()
|
||||
|
||||
if applied > 0 {
|
||||
a.invalidateAwardStats()
|
||||
a.clusterStatusMu.Lock()
|
||||
a.clusterStatusIdx = nil
|
||||
a.clusterStatusMu.Unlock()
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "logbook:changed")
|
||||
}
|
||||
}
|
||||
return applied, nil
|
||||
}
|
||||
|
||||
// applySyncRecord writes one incoming change to the logbook. Reports whether
|
||||
// anything actually changed.
|
||||
//
|
||||
// Deliberately uses the repository directly and NOT AddQSO/UpdateQSO/DeleteQSO:
|
||||
// those publish to the folder, and a change applied here would be written
|
||||
// straight back out — two machines echoing each other for ever.
|
||||
func (a *App) applySyncRecord(rec syncfolder.Record) bool {
|
||||
id, found, err := a.qso.IDBySyncUID(a.ctx, rec.UID)
|
||||
if err != nil {
|
||||
applog.Printf("foldersync: looking up %s: %v", rec.UID, err)
|
||||
return false
|
||||
}
|
||||
if rec.Op == syncfolder.OpDelete {
|
||||
if !found {
|
||||
return false // never had it, or already deleted here
|
||||
}
|
||||
if err := a.qso.Delete(a.ctx, id); err != nil {
|
||||
applog.Printf("foldersync: deleting QSO %d: %v", id, err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
var q qso.QSO
|
||||
if err := json.Unmarshal(rec.Data, &q); err != nil {
|
||||
applog.Printf("foldersync: unreadable record for %s: %v", rec.UID, err)
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(q.Callsign) == "" {
|
||||
return false
|
||||
}
|
||||
q.SyncUID = rec.UID
|
||||
// Not under this identity — but very possibly the same contact under
|
||||
// another one, or under none: both PCs were seeded from the same ADIF long
|
||||
// before any of this existed. Recognise it rather than log it twice.
|
||||
if !found {
|
||||
if lid, _, ok, err := a.qso.IDByDedupeKey(a.ctx, q.Callsign, q.QSODate.UTC().Format("2006-01-02T15:04"), q.Band, q.Mode); err == nil && ok {
|
||||
id, found = lid, true
|
||||
_ = a.qso.SetSyncUID(a.ctx, id, rec.UID)
|
||||
}
|
||||
}
|
||||
if found {
|
||||
q.ID = id
|
||||
if err := a.qso.Update(a.ctx, q); err != nil {
|
||||
applog.Printf("foldersync: updating QSO %d: %v", id, err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
q.ID = 0
|
||||
newID, err := a.qso.Add(a.ctx, q)
|
||||
if err != nil {
|
||||
applog.Printf("foldersync: inserting %s: %v", q.Callsign, err)
|
||||
return false
|
||||
}
|
||||
// sync_uid is not in the insert column list — on purpose, so no ordinary
|
||||
// write can clobber an identity — so it is stamped straight after.
|
||||
if err := a.qso.SetSyncUID(a.ctx, newID, rec.UID); err != nil {
|
||||
applog.Printf("foldersync: stamping the new QSO %d: %v", newID, err)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// GetFolderSyncStatus reports what the operator needs to see: which other PCs
|
||||
// are in the folder, when each last logged something, and whether anything is
|
||||
// waiting to be read.
|
||||
func (a *App) GetFolderSyncStatus() FolderSyncStatus {
|
||||
a.syncMu.Lock()
|
||||
cfg := a.loadFolderSync()
|
||||
store, _ := a.syncStore()
|
||||
st := FolderSyncStatus{
|
||||
Enabled: cfg.Enabled,
|
||||
Folder: cfg.Folder,
|
||||
Sent: a.syncSent,
|
||||
Received: a.syncReceived,
|
||||
Error: a.syncErr,
|
||||
}
|
||||
if !a.syncLast.IsZero() {
|
||||
st.LastSync = a.syncLast.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if a.settings != nil {
|
||||
st.MachineID, _ = a.settings.Get(a.ctx, keySyncFolderMachine)
|
||||
}
|
||||
a.syncMu.Unlock()
|
||||
if store == nil {
|
||||
return st
|
||||
}
|
||||
peers, err := store.Peers()
|
||||
if err != nil {
|
||||
st.Error = err.Error()
|
||||
return st
|
||||
}
|
||||
offsets := a.loadSyncOffsets()
|
||||
for _, p := range peers {
|
||||
fp := FolderSyncPeer{Machine: p.MachineID}
|
||||
if behind := p.Size - offsets[p.MachineID]; behind > 0 {
|
||||
fp.Behind = behind
|
||||
}
|
||||
if info, err := os.Stat(p.Path); err == nil {
|
||||
fp.LastChange = info.ModTime().UTC().Format(time.RFC3339)
|
||||
}
|
||||
st.Peers = append(st.Peers, fp)
|
||||
}
|
||||
return st
|
||||
}
|
||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
const (
|
||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||
appVersion = "0.25.4"
|
||||
appVersion = "0.25.8"
|
||||
|
||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||
// to https://us.i.posthog.com for a US project.
|
||||
|
||||
+38
-11
@@ -102,27 +102,54 @@ func (a *App) udpTriggerBandChange(s cat.RigState) {
|
||||
if a.udp == nil {
|
||||
return
|
||||
}
|
||||
band := strings.ToLower(strings.TrimSpace(s.Band))
|
||||
if band == "" || !s.Connected {
|
||||
if !s.Connected {
|
||||
return
|
||||
}
|
||||
lastTriggerBandMu.Lock()
|
||||
changed := band != lastTriggerBand
|
||||
if changed {
|
||||
lastTriggerBand = band
|
||||
a.emitBandChangeTrigger(s.Band, s.Mode, s.FreqHz)
|
||||
}
|
||||
|
||||
// noteBandChange normalises a band and reports whether it is a NEW one.
|
||||
//
|
||||
// The state is shared by both sources on purpose. The rig reports a band change
|
||||
// and so does the entry strip, and on a station that has both, one QSY produces
|
||||
// both — an antenna switch must be commanded once, not twice.
|
||||
func noteBandChange(raw string) (string, bool) {
|
||||
band := strings.ToLower(strings.TrimSpace(raw))
|
||||
if band == "" {
|
||||
return "", false
|
||||
}
|
||||
lastTriggerBandMu.Unlock()
|
||||
lastTriggerBandMu.Lock()
|
||||
defer lastTriggerBandMu.Unlock()
|
||||
if band == lastTriggerBand {
|
||||
return band, false
|
||||
}
|
||||
lastTriggerBand = band
|
||||
return band, true
|
||||
}
|
||||
|
||||
// emitBandChangeTrigger fires the band-change rows, once per NEW band.
|
||||
//
|
||||
// Split out of the rig-state path because the rig is not the only thing that
|
||||
// changes band: a station with no CAT link changes it in the QSO entry strip,
|
||||
// and that is just as much a band change to the antenna switch on the other end
|
||||
// of the message. The de-duplication is shared, so the two sources cannot
|
||||
// double-fire between them.
|
||||
func (a *App) emitBandChangeTrigger(rawBand, mode string, freqHz int64) {
|
||||
if a.udp == nil {
|
||||
return
|
||||
}
|
||||
band, changed := noteBandChange(rawBand)
|
||||
if !changed {
|
||||
return
|
||||
}
|
||||
f := map[string]string{
|
||||
"band": band,
|
||||
"band_m": bandMetres(band),
|
||||
"mode": s.Mode,
|
||||
"mode": mode,
|
||||
}
|
||||
if s.FreqHz > 0 {
|
||||
f["freq_hz"] = strconv.FormatInt(s.FreqHz, 10)
|
||||
f["freq_mhz"] = fmt.Sprintf("%.6f", float64(s.FreqHz)/1e6)
|
||||
if freqHz > 0 {
|
||||
f["freq_hz"] = strconv.FormatInt(freqHz, 10)
|
||||
f["freq_mhz"] = fmt.Sprintf("%.6f", float64(freqHz)/1e6)
|
||||
}
|
||||
a.udp.EmitTrigger(udp.TriggerBandChange, f)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,11 @@ import (
|
||||
// build (the exe lives there; source stays on Gitea). Adjust the repo if needed.
|
||||
const updateCheckURL = "https://api.github.com/repos/GregTroar/OpsLog/releases/latest"
|
||||
|
||||
// releasesPageURL is the same release, for people rather than for the updater:
|
||||
// the API address above answers JSON, so it is not something to put in front of
|
||||
// an operator who followed a link out of a QSL e-mail.
|
||||
const releasesPageURL = "https://github.com/GregTroar/OpsLog/releases/latest"
|
||||
|
||||
// UpdateInfo is the result of the version check.
|
||||
type UpdateInfo struct {
|
||||
Current string `json:"current"` // this build's version (appVersion)
|
||||
@@ -367,8 +372,8 @@ func cleanupOldUpdateBinary() {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.Remove(exe + ".old") // the old fixed name
|
||||
_ = os.Remove(exe + ".new") // a deferred swap that has been applied
|
||||
_ = os.Remove(exe + ".old") // the old fixed name
|
||||
_ = os.Remove(exe + ".new") // a deferred swap that has been applied
|
||||
matches, err := filepath.Glob(exe + ".old-*")
|
||||
if err != nil {
|
||||
return
|
||||
|
||||
@@ -55,6 +55,8 @@ Over TCP / GSCP — a docked **A/B antenna-switch** widget:
|
||||
|
||||
Configure the host / password in Settings → Antenna Genius.
|
||||
|
||||
---
|
||||
|
||||
## Relay boards (Station Control)
|
||||
|
||||
Relay boards for antenna/accessory switching, with named buttons in Station
|
||||
@@ -68,11 +70,139 @@ Control:
|
||||
web page — a factory board needs neither.
|
||||
- **Denkovi** USB (4/8 relays, FT245) and generic **CH340 / LCUS** USB-serial
|
||||
boards.
|
||||
- **HTTP relay (home-made / generic)** — see below.
|
||||
|
||||
A Test-connection button and detection feedback live in the setup panel.
|
||||
### HTTP relay (home-made / generic)
|
||||
|
||||
For any box that switches on a plain HTTP GET: an ESP8266 with a web page, a
|
||||
Shelly, a Sonoff on third-party firmware, a qro.cz board, a home-brew switch.
|
||||
Pick the relay count, name the relays, and give the addresses.
|
||||
|
||||
There is **no Host field and no connection test**. This device type has no
|
||||
address of its own — each relay carries its own full URL, and they need not
|
||||
even be on the same box. Nothing is read back either: OpsLog remembers what it
|
||||
commanded, so after a restart each managed relay is re-commanded once.
|
||||
|
||||
**Two ways to give the addresses**, and you can mix them.
|
||||
|
||||
**A — one URL per relay.** The reason this type exists: a hand-made switch
|
||||
often has addresses with nothing in common between channels.
|
||||
|
||||
| Relay | ON URL | OFF URL |
|
||||
|---|---|---|
|
||||
| 1 | `http://192.168.1.9/FF0101` | `http://192.168.1.9/FF0100` |
|
||||
| 2 | `http://192.168.1.9/relay2/on` | `http://192.168.1.9/relay2/off` |
|
||||
|
||||
**B — one pattern for the whole board**, with the relay number filled in:
|
||||
|
||||
```
|
||||
ON pattern: http://192.168.1.9/relay?n={relay}&state=on
|
||||
OFF pattern: http://192.168.1.9/relay?n={relay}&state=off
|
||||
```
|
||||
|
||||
#### Substitutions
|
||||
|
||||
| In a URL or a pattern | Becomes |
|
||||
|---|---|
|
||||
| `{relay}` | the relay number, 1-based |
|
||||
| `{relay-1}` | the relay number counting **from zero** — for boards whose channels are 0, 1, 2… |
|
||||
| `{value}` | **that relay's label**, from the *Relay labels* row below |
|
||||
|
||||
`http` may be left off — it is assumed. `https` works and is used as typed.
|
||||
|
||||
`{value}` is for a switch addressed by antenna name rather than by channel
|
||||
number. Name relay 1 **Ant1**, and:
|
||||
|
||||
```
|
||||
Per-relay ON URL for relay 1: http://10.10.10.100/relay?on={value}
|
||||
Sent: http://10.10.10.100/relay?on=Ant1
|
||||
```
|
||||
|
||||
Rename the antenna and the address follows it — the name on the button and the
|
||||
name on the wire cannot drift apart, because they are the same text. A label
|
||||
with a space or an accent is percent-encoded automatically. A relay whose URL
|
||||
uses `{value}` **must have a label**; the editor warns you while you type.
|
||||
|
||||
A per-relay URL always wins over the pattern, so you can write a pattern for
|
||||
the seven ordinary channels and one full URL for the odd one out.
|
||||
|
||||
### Manual switching
|
||||
|
||||
Every relay is a button in **Station Control** — click to toggle. The generic
|
||||
HTTP board is never greyed out (there is no connection to wait for); the other
|
||||
types are enabled once the board answers.
|
||||
|
||||
---
|
||||
|
||||
## Relay automatic control
|
||||
|
||||
Settings → **Relay automatic control** turns relays by frequency or band, the
|
||||
equivalent of PstRotator's automatic control. Each relay of each configured
|
||||
board gets a rule:
|
||||
|
||||
| Mode | Meaning |
|
||||
|---|---|
|
||||
| **Off** | OpsLog never touches this relay. |
|
||||
| **Band** | ON while the current band is one of the bands you tick. |
|
||||
| **Frequency** | ON while the frequency is inside a kHz range you type. |
|
||||
|
||||
Example — a three-way antenna switch on a KMTronic board:
|
||||
|
||||
| Relay | Label | Mode | Setting |
|
||||
|---|---|---|---|
|
||||
| 1 | Beam 20-10 | Band | 20m, 17m, 15m, 12m, 10m |
|
||||
| 2 | 40 m dipole | Band | 40m |
|
||||
| 3 | 80 m inverted-V | Band | 80m, 160m |
|
||||
| 4 | 6 m preamp | Frequency | 50000 – 54000 kHz |
|
||||
|
||||
Rules to know:
|
||||
|
||||
- A relay is only switched when it is **not already** in the wanted position.
|
||||
On the first evaluation after launch OpsLog reads the boards' live state, so
|
||||
a relay already correct is left alone rather than clunking.
|
||||
- An **unknown** frequency or band changes nothing. When CAT disconnects the
|
||||
frequency reads zero, and treating that as "out of range" would switch every
|
||||
relay off.
|
||||
- The rules follow **your radio**. Changing band in the QSO entry strip drives
|
||||
the rig, the rig reports back, and the relays follow. **Without a CAT
|
||||
connection** the Band selector itself is the band change, and the relays
|
||||
follow that instead — unless the band or frequency 🔒 is on, which means the
|
||||
entry is deliberately decoupled from the rig (logging an old contact) and no
|
||||
hardware should move.
|
||||
|
||||
For a switch driven by a URL or a UDP message rather than by a relay board, see
|
||||
[[Connections]].
|
||||
|
||||
---
|
||||
|
||||
## Bench power supply
|
||||
|
||||
Settings → **Power supply**. A programmable supply on a serial port speaking
|
||||
**Modbus RTU** — BSIDE, Wanptek and the other supplies that use function codes
|
||||
03 and 06.
|
||||
|
||||
Give the COM port. The other two fields are the factory settings and only need
|
||||
changing if you changed them on the supply: **9600 baud**, **Modbus address 1**
|
||||
(8 data bits, no parity, 1 stop bit).
|
||||
|
||||
A card appears in **Station Control**: an **Output** on/off button, the measured
|
||||
volts / amps / watts, and — in small type — the voltage and current the supply
|
||||
is *set* to. That pairing is the useful part: `13.80 V set` beside `0.020 A`
|
||||
says at a glance that the supply is on and the radio is drawing nothing.
|
||||
|
||||
> **OpsLog only ever writes the output on/off.** The supply's register map also
|
||||
> exposes the voltage and current set points and the three protection trip
|
||||
> levels, and none of them belong to a logbook — a wrong value there is 30 V
|
||||
> where a radio expected 13.8. They are read and displayed, never changed.
|
||||
> Set them on the supply's own front panel.
|
||||
|
||||
If the protection has tripped, the card says so with the supply's status word.
|
||||
|
||||
---
|
||||
|
||||
## Station Control
|
||||
|
||||
The Station Control tab is a dashboard of fixed-width panels that wrap to fill
|
||||
the window — amplifier(s), tuner, relays, rotator, Ultrabeam… Drag a panel by
|
||||
the grip on its left edge to reorder; the order is remembered.
|
||||
the window — amplifier(s), tuner, relays, rotator, power supply, Ultrabeam…
|
||||
Drag a panel by the grip on its left edge to reorder; the order is remembered.
|
||||
A column-count selector (Auto / 1–6) caps how many sit side by side.
|
||||
|
||||
@@ -163,3 +163,40 @@ built-in award without fear of a future release overwriting it.
|
||||
|
||||
**Rescan** re-pulls the logbook and recomputes — it picks up fresh LoTW / QRZ /
|
||||
eQSL confirmations (see [[QSL Management]]).
|
||||
|
||||
---
|
||||
|
||||
## US counties (CQ USA-CA)
|
||||
|
||||
A county award needs a county on every US QSO, and most logs do not have one:
|
||||
neither the callsign nor the prefix carries it, and few operators send it.
|
||||
|
||||
**Settings → US counties** downloads the FCC **ULS** database — every US
|
||||
amateur licence with its address — and builds a local index of callsign →
|
||||
county + grid. It is a few hundred megabytes to fetch and takes a few minutes;
|
||||
once built it works offline and needs no key.
|
||||
|
||||
With it downloaded:
|
||||
|
||||
- A **new US QSO** gets its county (and grid, if you have none) filled in
|
||||
automatically — **only where the field is empty**. A value you or QRZ
|
||||
supplied is never overwritten: the ZIP-derived county is about 98 % right and
|
||||
the one you logged is usually better.
|
||||
- **Right-click → Update US county from the ULS database** re-derives the
|
||||
county of the selected contacts and **replaces** what is stored. That is the
|
||||
deliberate opposite of the automatic pass, and it is the way to correct
|
||||
counties already in the log — see the Connecticut note below. Contacts
|
||||
outside the US, and callsigns the database does not hold, are left alone.
|
||||
The entry only appears once the database is downloaded.
|
||||
|
||||
### Connecticut
|
||||
|
||||
Connecticut **abolished its eight counties** for statistical purposes in 2022;
|
||||
the Census Bureau replaced them with nine **planning regions**. CQ USA-CA still
|
||||
uses the old county names, so OpsLog maps a Connecticut address back to its
|
||||
legal county.
|
||||
|
||||
If your database was built by an older OpsLog, the Settings panel says so and
|
||||
offers a refresh — a download date alone cannot show it, because a database
|
||||
fetched yesterday by an older version still holds the wrong Connecticut
|
||||
counties. After refreshing, use the right-click update on your CT contacts.
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
# Connections
|
||||
|
||||
Settings → **Connections** is where OpsLog talks to other programs and to
|
||||
hardware over the network: what it **listens** to, what it **sends**, and
|
||||
messages you write yourself.
|
||||
|
||||
It was called *UDP* until it grew a second transport — a row can now send an
|
||||
**HTTP GET** instead of a datagram, which is what most home-made antenna
|
||||
switches understand.
|
||||
|
||||
Each row has a name, a direction, a service type, a port, and an on/off switch.
|
||||
**Everything sent and received is written to the application log**, so a row
|
||||
that does not work can be diagnosed rather than guessed at — see
|
||||
[[Troubleshooting]] for where the log lives.
|
||||
|
||||
---
|
||||
|
||||
## Inbound — what OpsLog listens to
|
||||
|
||||
| Service | Sends it | Use |
|
||||
|---|---|---|
|
||||
| **WSJT-X / JTDX / MSHV** | those programs, port 2237 | Log their QSOs automatically, follow the DX call being worked, and show their decodes. Multicast is normal here. |
|
||||
| **ADIF over UDP** | JTAlert, GridTracker, FLDIGI | A text ADIF record per logged QSO. |
|
||||
| **N1MM Logger+** | N1MM | Its XML contact record. |
|
||||
| **Remote callsign** | DXHunter and similar | A callsign — and optionally a frequency and mode — to load into the entry form and tune to. |
|
||||
|
||||
### Multicast or unicast?
|
||||
|
||||
WSJT-X and MSHV normally broadcast to a **multicast group** (usually
|
||||
`239.255.0.1`) so several programs can hear them at once. Tick *Multicast* and
|
||||
give the group. A program sending to one address only needs unicast — leave it
|
||||
unticked.
|
||||
|
||||
> **Do not put an inbound row and an outbound row on the same port.** OpsLog
|
||||
> then receives its own messages, and something it publishes can come back as a
|
||||
> command. It says so in the log when it spots the arrangement:
|
||||
> *"X sends on port 2241 and Y listens on it — OpsLog will receive its own
|
||||
> messages there; give one of the two another port."*
|
||||
|
||||
---
|
||||
|
||||
## Outbound — what OpsLog sends
|
||||
|
||||
| Service | Format | Fires on |
|
||||
|---|---|---|
|
||||
| **ADIF message** | a plain ADIF record | each QSO logged |
|
||||
| **WSJT-X logged QSO** | the same ADIF wrapped in a WSJT-X datagram | each QSO logged |
|
||||
| **PstRotator frequency** | `<PST><FREQUENCY>` | frequency change |
|
||||
| **N1MM RadioInfo** | N1MM's RadioInfo XML | frequency or mode change |
|
||||
| **Custom message** | whatever you write | a trigger you pick |
|
||||
|
||||
### Which one for another logger?
|
||||
|
||||
If a logger says it accepts "WSJT-X UDP", it wants the **WSJT-X logged QSO**
|
||||
row — it listens on the WSJT-X interface and silently discards a bare ADIF
|
||||
record. Logger32 is the usual case. A logger that documents a plain ADIF
|
||||
listener wants the **ADIF message** row instead. They are two rows because they
|
||||
are two different things on the wire; enable the one your logger asks for, not
|
||||
both, or the QSO arrives twice.
|
||||
|
||||
---
|
||||
|
||||
## Custom messages
|
||||
|
||||
The general case: **you choose when it fires, what it says, and how it leaves.**
|
||||
This is how a home-made antenna switch, a relay box or a home-automation server
|
||||
gets told what the station is doing.
|
||||
|
||||
### 1. Pick the trigger
|
||||
|
||||
| Trigger | Fires when |
|
||||
|---|---|
|
||||
| **QSO logged** | a contact is saved |
|
||||
| **Band change** | the radio changes band — or, on a station with no CAT, the Band selector in the entry strip |
|
||||
| **Rotator command** | the antenna is told to turn (compass, SP/LP buttons, a spot click) |
|
||||
| **Lookup done** | a callbook lookup returns |
|
||||
|
||||
### 2. Write the message
|
||||
|
||||
Anything in `{braces}` is replaced. What is available depends on the trigger:
|
||||
|
||||
| Trigger | Placeholders |
|
||||
|---|---|
|
||||
| QSO logged | `{call}` `{band}` `{band_m}` `{mode}` `{grid}` `{name}` `{country}` `{rst_s}` `{rst_r}` `{comment}` `{date}` `{time}` `{freq_hz}` `{freq_mhz}` `{dxcc}` |
|
||||
| Band change | `{band}` `{band_m}` `{mode}` `{freq_hz}` `{freq_mhz}` |
|
||||
| Rotator command | `{az}` `{el}` `{path}` (`SP`, `LP` or empty) |
|
||||
| Lookup done | `{call}` `{name}` `{grid}` `{country}` `{qth}` `{state}` `{dxcc}` |
|
||||
|
||||
`{band}` is `20m`; `{band_m}` is just `20`, because an antenna switch usually
|
||||
wants the number and nothing else.
|
||||
|
||||
### 3. Choose the transport
|
||||
|
||||
- **UDP** — a datagram to an address and port. You choose the line ending.
|
||||
- **URL** — an HTTP GET. Values are URL-encoded automatically.
|
||||
|
||||
### Examples
|
||||
|
||||
**Turn an antenna switch on every band change, over HTTP:**
|
||||
|
||||
```
|
||||
Trigger: Band change
|
||||
Transport: URL
|
||||
URL: http://192.168.1.50/set?band={band_m}
|
||||
```
|
||||
20 m → `http://192.168.1.50/set?band=20`
|
||||
|
||||
**Tell a rotator display where the antenna is going:**
|
||||
|
||||
```
|
||||
Trigger: Rotator command
|
||||
Transport: UDP → 192.168.1.77:8100
|
||||
Message: <AZIMUT>{az}</AZIMUT><PATH>{path}</PATH>
|
||||
```
|
||||
|
||||
**Announce each QSO to a shack dashboard:**
|
||||
|
||||
```
|
||||
Trigger: QSO logged
|
||||
Transport: URL
|
||||
URL: http://homeassistant.local:8123/api/webhook/qso?call={call}&band={band}&mode={mode}
|
||||
```
|
||||
|
||||
> Credentials in a URL are sent as typed — this is meant for a LAN. Passwords
|
||||
> are **redacted in the log** so a log file can be shared safely.
|
||||
|
||||
### When a band change is a band change
|
||||
|
||||
The Band-change trigger follows **the radio**, because an antenna switch should
|
||||
follow the radio and not what is being typed. Changing band in the entry strip
|
||||
drives the rig, the rig reports the new band, and the trigger fires from that.
|
||||
|
||||
On a station whose rig OpsLog does not control there is nothing to report back,
|
||||
so the **Band selector itself** counts as the band change. The 🔒 lock on Band
|
||||
or Frequency suppresses it: a lock means the entry is deliberately decoupled
|
||||
from the rig, and nothing should move for a contact logged from last year.
|
||||
|
||||
---
|
||||
|
||||
## Diagnosing a row that does nothing
|
||||
|
||||
The log names every message. In order, check:
|
||||
|
||||
1. **Is the row enabled?** The list shows it.
|
||||
2. **`udp: Reload done — N server(s) running`** at startup, and one
|
||||
`cfg id=… name=… dir=… service=… port=…` line per row. A row that failed to
|
||||
start is listed with its error.
|
||||
3. **Outbound:** `udp: [NAME] sent N bytes to ADDRESS (service)` for a datagram,
|
||||
or the URL for an HTTP row.
|
||||
4. **A message that renders empty is skipped** and the log says so — usually a
|
||||
placeholder that the trigger does not provide.
|
||||
5. **`a QSO was logged but no outbound "ADIF message" row is enabled`** — the
|
||||
answer to "why does my other logger get nothing".
|
||||
|
||||
See also: [[Amplifiers and Switches]] for relay boards and their own automatic
|
||||
control, and [[DX Cluster and Spots]] for spot sources.
|
||||
@@ -56,3 +56,20 @@ A **DXHunter** remote-call UDP packet fills the callsign, and when it carries
|
||||
[[Settings and Data]] for the UDP configuration.
|
||||
|
||||
See also: [[Maps and Antennas]] for turning the beam to a spot.
|
||||
|
||||
## When the list looks empty
|
||||
|
||||
If spots are arriving and none are shown, the panel says so, names the filters
|
||||
responsible and offers a button to clear them all. The two easiest to forget are
|
||||
the **band and mode locks** (🔒), because they follow the radio rather than a
|
||||
click — with the rig on 20 m SSB and both locked, a hundred spots on other bands
|
||||
show nothing.
|
||||
|
||||
Filter selections are remembered between sessions; a fresh installation starts
|
||||
with none set.
|
||||
|
||||
## Spotting
|
||||
|
||||
When you send a spot, the comment carries the mode and then the **award
|
||||
references you have assigned** to that contact — the ones on the QSO, not the
|
||||
ones OpsLog would compute for it.
|
||||
|
||||
@@ -23,14 +23,41 @@ portable contacts. No API key.
|
||||
## Rotators
|
||||
|
||||
An azimuthal-equidistant **click-to-turn** compass; the current heading and
|
||||
target are shown. Three rotator types (Settings → Rotator):
|
||||
target are shown. The needle follows a turning antenna smoothly — OpsLog polls
|
||||
faster while the position is changing and backs off once it is parked.
|
||||
|
||||
- **PstRotator** — via its UDP interface.
|
||||
- **microHAM ARCO** — native, over LAN or USB: set the ARCO's CONTROL PROTOCOL
|
||||
to *Yaesu GS-232A*.
|
||||
- **GS-232A (generic)** — any GS-232A controller, ERC (Easy Rotor Control)
|
||||
included; serial speed selectable. Set an ERC to GS-232 emulation, not
|
||||
Hy-Gain DCU-1.
|
||||
**Two rotors** can be configured (two towers, or a Rotator Genius driving two).
|
||||
A Rotor 1 / 2 selector appears on the compass; OpsLog turns and displays the
|
||||
one you pick.
|
||||
|
||||
### Types (Settings → Rotator)
|
||||
|
||||
| Type | Connection | Notes |
|
||||
|---|---|---|
|
||||
| **PstRotator** | UDP | Enable PstRotator's UDP listener (Setup → Communication → UDP). |
|
||||
| **Rotator Genius** (4O3A) | TCP, port 9006 | Native. *Rotator #* picks which of the two the box drives; *Two rotors* adds the second as its own rotor. |
|
||||
| **microHAM ARCO / GS-232A** | LAN or USB | Set the controller's CONTROL PROTOCOL to *Yaesu GS-232A*. An **ERC** must be in GS-232 emulation, **not** Hy-Gain DCU-1. |
|
||||
| **Hy-Gain DCU-1** | COM port or serial-over-IP | RotorCard DXA, Idiom Press Rotor-EZ, Green Heron. Azimuth only. A DCU-1 is 4800 baud; others may differ — match the controller. |
|
||||
| **SPID / AlfaSpid** | COM port | Native, so PstRotator is not needed in between. See below. |
|
||||
|
||||
### SPID / AlfaSpid
|
||||
|
||||
Pick the dialect to match the controller — they are not interchangeable:
|
||||
|
||||
- **Rot2Prog** — RAS, BIG-RAS/HR, MD-01, MD-02. Azimuth **and** elevation.
|
||||
600 baud.
|
||||
- **Rot1Prog** — the older azimuth-only controllers (RAK, RAU). 1200 baud.
|
||||
|
||||
The two use different frame lengths, so a wrong choice shows up at once: the
|
||||
**Test** button reports a reply of the wrong length rather than leaving you to
|
||||
find out when the antenna turns oddly.
|
||||
|
||||
Two towers means two controllers, each on its own COM port and each a separate
|
||||
rotor in OpsLog.
|
||||
|
||||
> **The Test button on a SPID, an ARCO or a DCU-1 only READS the heading** — it
|
||||
> confirms the port, the speed and the dialect without moving anything, and
|
||||
> says so. (PstRotator and a Rotator Genius are instead sent a move to 0°.)
|
||||
|
||||
## Motorized antennas (Ultrabeam / SteppIR)
|
||||
|
||||
|
||||
@@ -51,3 +51,13 @@ This is also what powers [[Multi-Operator Live Status]].
|
||||
Optional **database + ADIF backup at shutdown** (Settings → Backup). Regardless,
|
||||
copying the `data/` folder backs up your settings and local logbook. See
|
||||
[[Settings and Data]].
|
||||
|
||||
## What follows the active profile
|
||||
|
||||
Every setting is per profile, station hardware included. Switching profile
|
||||
reconnects the **CAT link**, the **amplifiers**, the **motorized antenna**, the
|
||||
**Antenna Genius** and the **tuner** using the new profile's settings — so an
|
||||
operator with, say, an SPE on COM9 for HF and another on COM10 for 6 m gets the
|
||||
right one by switching profile, with no visit to Settings.
|
||||
|
||||
The Winkeyer is deliberately left alone: you connect it explicitly.
|
||||
|
||||
@@ -34,6 +34,38 @@ auto-refreshes the **award** stats.
|
||||
- **LoTW upload** goes through **TQSL** (ARRL's signer) — set the TQSL path and
|
||||
station location in Settings.
|
||||
|
||||
## Paper QSL: sent, received, and how
|
||||
|
||||
The QSL Manager's paper form sets four things on a batch at once — the sent
|
||||
status and date, the received status and date — plus **how the card travelled**,
|
||||
in each direction.
|
||||
|
||||
Two ADIF fields are involved and they are **not** the same thing:
|
||||
|
||||
| Field | Holds | Example |
|
||||
|---|---|---|
|
||||
| `QSL_VIA` | the **manager's callsign** | `M0OXO` |
|
||||
| `QSL_SENT_VIA` / `QSL_RCVD_VIA` | **how the card travelled** — an enumeration | `B` bureau · `D` direct · `E` electronic |
|
||||
|
||||
Some loggers write the routing into the manager field, and OpsLog's own older
|
||||
versions wrote the words "Bureau" / "Direct" / "Electronic" there too. If your
|
||||
log has a mixture, OpsLog offers a **repair** — it tells you how many QSOs are
|
||||
affected **before** it changes anything, and it only moves values that are
|
||||
unmistakably a routing method (a manager is a callsign, never one of those six
|
||||
words).
|
||||
|
||||
On **import**, the two fields are kept apart and nothing is invented: a file
|
||||
without a routing gives an empty routing. If you see `E` on every imported QSO
|
||||
it came from the file — Log4OM writes `QSL_SENT_VIA:E` on every record whether
|
||||
a card was ever sent. To clear them, filter the imported QSOs and use
|
||||
**Bulk edit field** with an empty value ([[Recent QSOs and Filters]]).
|
||||
|
||||
## Confirmed means Y or V
|
||||
|
||||
A LoTW confirmation can come back as **V** (verified) rather than **Y**. Both
|
||||
count as confirmed — in the awards, the band/mode matrix, the slot statistics,
|
||||
the row colours and the QSL Info tab alike.
|
||||
|
||||
## E-mail eQSL
|
||||
|
||||
Right-click a QSO → **Send eQSL by e-mail** via the configured SMTP account. The
|
||||
|
||||
+117
-10
@@ -2,29 +2,64 @@
|
||||
|
||||
## The Recent QSOs grid
|
||||
|
||||
The main log table. Double-click a row to edit; right-click for bulk actions
|
||||
(see [[Logging Basics]]). Columns are configurable — click **Columns** to choose
|
||||
which are visible; widths, order and hidden columns are saved (per profile),
|
||||
with a separate layout for the narrower Main-tab pane.
|
||||
The main log table. Double-click a row to edit; **right-click** for everything
|
||||
else (see [The right-click menu](#the-right-click-menu) below). Columns are
|
||||
configurable — click **Columns** to choose which are visible; widths, order and
|
||||
hidden columns are saved (per profile), with a separate layout for the narrower
|
||||
Main-tab pane.
|
||||
|
||||
- The selected-row count is always visible at the top, and a **Select all /
|
||||
Unselect all** button takes every displayed row.
|
||||
- QSL and upload status columns are coloured: **Y** green, **N** red, **R**
|
||||
blue.
|
||||
blue, **V** (verified) green — a LoTW contact marked *V* counts as confirmed
|
||||
everywhere, like *Y*.
|
||||
- A **Distance (km)** column (also in Worked-before) is computed from the QSO's
|
||||
own locator pair.
|
||||
- There are **no per-column header filters** — they only ever searched the rows
|
||||
on screen, which made a QSO further back in the log look missing. Use the
|
||||
advanced filter, which queries the whole logbook.
|
||||
|
||||
A QSO logged from **WSJT-X, MSHV, JTDX or a net** appears here immediately, not
|
||||
when its auto-upload eventually goes out. That matters if you use a *delayed*
|
||||
upload to leave yourself time to correct a contact before it is sent.
|
||||
|
||||
### Award columns
|
||||
|
||||
Each defined award can show a column with the reference the QSO counts for.
|
||||
These columns are **hidden by default** and opt-in from the Columns picker.
|
||||
|
||||
---
|
||||
|
||||
## The right-click menu
|
||||
|
||||
Select one or more rows, right-click, and the menu acts on **the whole
|
||||
selection**. Right-clicking a row that is not selected selects it first, so a
|
||||
single right-click on one QSO always does what it looks like it will.
|
||||
|
||||
| Entry | What it does |
|
||||
|---|---|
|
||||
| **Fix country & zones from cty.dat** | Re-derives DXCC, continent, CQ and ITU zones from the prefix database. Use after a cty.dat update, or on QSOs imported without zones. |
|
||||
| **Update from the callsign databases** | Re-queries QRZ.com / HamQTH per QSO. A network round trip each — a progress bar appears. |
|
||||
| **Update from ClubLog (exceptions)** | Applies Club Log's exception list: callsigns whose real entity is not what the prefix says. |
|
||||
| **Update US county from the ULS database** | Re-derives the county of US contacts and **replaces** what is stored. Only appears once the county database is downloaded — see [[Awards]]. |
|
||||
| **Send OpsLog QSL by e-mail** | Renders the [[QSL Card Designer]] card for that QSO and mails it. |
|
||||
| **Bulk edit field…** | One field, one value, on every selected QSO. Leaving the value **empty clears** the field. |
|
||||
| **Export selected to ADIF / Cabrillo** | Just the rows you picked. |
|
||||
| **Export selected — choose fields…** | Same, but you pick which ADIF fields go in the file. |
|
||||
| **Export filtered view to ADIF / Cabrillo** | **Every QSO the current filter matches** — not only the rows on screen, and with no row limit. |
|
||||
| **Send to QRZ / Club Log / HRDLog / eQSL / LoTW** | Uploads the selection now, whatever the automatic upload mode is. |
|
||||
| **Delete…** | With a confirmation naming the count. |
|
||||
|
||||
> **Exporting a filtered search is two different entries.** *Export selected*
|
||||
> takes the rows you highlighted. *Export filtered view* takes the whole result
|
||||
> of the filter, including the QSOs scrolled off the bottom. When you have just
|
||||
> built a filter and want the lot, it is the second one.
|
||||
|
||||
---
|
||||
|
||||
## Advanced filter builder
|
||||
|
||||
Click the filter button to open the **QSO filter builder** (Log4OM-style):
|
||||
Click the filter button to open the **QSO filter builder** (Log4OM-style).
|
||||
|
||||
- Add one or more **conditions**: *field · operator · value*.
|
||||
- Operators: equals, not-equal, contains, starts/ends with, greater/less
|
||||
@@ -32,12 +67,84 @@ Click the filter button to open the **QSO filter builder** (Log4OM-style):
|
||||
- Fields include every confirmation **status and date** (sent / received,
|
||||
before / after) for paper QSL, LoTW, eQSL, Club Log, HRDLog and QRZ.com.
|
||||
Field names stay in English — they are ADIF names, a standard vocabulary.
|
||||
- Join conditions with **ALL (AND)** or **ANY (OR)**.
|
||||
- Join the conditions with **ALL (AND)** or **ANY (OR)**.
|
||||
- Save named **presets** (they travel with the `data/` folder).
|
||||
|
||||
A filter shows **every** match (the on-screen row limit only applies to the
|
||||
unfiltered log; safety cap 10 000). The filtered view can be exported:
|
||||
**selected rows** or the **whole filtered view** → ADIF or Cabrillo.
|
||||
A filter shows **every** match — the on-screen row limit only applies to the
|
||||
unfiltered log (safety cap 10 000).
|
||||
|
||||
### Worked examples
|
||||
|
||||
Each of these is a few clicks in the builder. The point of the last column is
|
||||
what you then do with the result.
|
||||
|
||||
**1. Everything worked on 20 m in FT8 that is still unconfirmed on LoTW**
|
||||
|
||||
| Join | Field | Operator | Value |
|
||||
|---|---|---|---|
|
||||
| ALL | `band` | equals | `20m` |
|
||||
| | `mode` | equals | `FT8` |
|
||||
| | `lotw_qsl_rcvd` | is empty | |
|
||||
|
||||
→ right-click → **Export filtered view to ADIF**, and you have the file to
|
||||
re-upload or to check against LoTW.
|
||||
|
||||
**2. Paper cards sent but never answered, older than a year**
|
||||
|
||||
| Join | Field | Operator | Value |
|
||||
|---|---|---|---|
|
||||
| ALL | `qsl_sent` | equals | `Y` |
|
||||
| | `qsl_rcvd` | is empty | |
|
||||
| | `qso_date` | less than | `20250816` |
|
||||
|
||||
→ select all → **Bulk edit field** to mark them for a bureau chase, or export
|
||||
them for a follow-up list.
|
||||
|
||||
**3. Every contact with one entity, on any band, in a date range**
|
||||
|
||||
| Join | Field | Operator | Value |
|
||||
|---|---|---|---|
|
||||
| ALL | `dxcc` | equals | `291` |
|
||||
| | `qso_date` | greater or equal | `20260101` |
|
||||
| | `qso_date` | less or equal | `20261231` |
|
||||
|
||||
→ this is the shape for an award submission. Save it as a **preset** so next
|
||||
year is one click and a date change.
|
||||
|
||||
**4. Anything that might be a duplicate of tonight's run**
|
||||
|
||||
| Join | Field | Operator | Value |
|
||||
|---|---|---|---|
|
||||
| ALL | `call` | contains | `DL` |
|
||||
| | `band` | equals | `40m` |
|
||||
| ANY | `qsl_sent` | equals | `Y` |
|
||||
| | `lotw_qsl_sent` | equals | `Y` |
|
||||
|
||||
Note the mix: the **ALL** block narrows to the contacts you care about, the
|
||||
**ANY** block accepts a QSO confirmed by either route.
|
||||
|
||||
**5. US contacts with no county recorded**
|
||||
|
||||
| Join | Field | Operator | Value |
|
||||
|---|---|---|---|
|
||||
| ALL | `dxcc` | equals | `291` |
|
||||
| | `cnty` | is empty | |
|
||||
|
||||
→ select all → right-click → **Update US county from the ULS database**.
|
||||
|
||||
### Tips
|
||||
|
||||
- **Dates are ADIF dates**: `YYYYMMDD`, no separators. `20260816`, not
|
||||
`16/08/2026`.
|
||||
- **`is empty` is not the same as `equals N`.** A QSL that was never sent is
|
||||
empty; one explicitly marked *not sent* holds `N`. Filters on confirmation
|
||||
usually want *is empty*.
|
||||
- To find what a field is called, show its column in the grid: the Columns
|
||||
picker and the filter builder use the same ADIF names.
|
||||
- A preset saves the conditions, not the results — it re-runs against the log
|
||||
as it is today.
|
||||
|
||||
---
|
||||
|
||||
## Worked-before matrix
|
||||
|
||||
|
||||
@@ -76,3 +76,40 @@ selectable, with a copy button.
|
||||
|
||||
Open an issue (or *Send log to F4BPO*) with: what you did, what happened, your
|
||||
radio/model, and the relevant log lines.
|
||||
|
||||
## The rig disconnects during digital transmissions
|
||||
|
||||
Symptom: with WSJT-X or JTDX set to **Fake It** split, the CAT link drops on
|
||||
every over. Fake It shifts the dial for each transmission instead of using
|
||||
split, so it changes the radio state twice per cycle.
|
||||
|
||||
Check that **no inbound Connections row shares a port with an outbound one**.
|
||||
If it does, OpsLog receives what it publishes, and its own frequency broadcast
|
||||
can come back as a command to re-tune — a loop that fires on every dial shift.
|
||||
The log names the clash at startup. Give one of the two rows another port.
|
||||
|
||||
## A motorized antenna does not follow the rig
|
||||
|
||||
Look for this line in the log:
|
||||
|
||||
antenna: steppir connected, but TRACKING IS OFF in Settings — it will not
|
||||
follow the rig, and only moves when you tune it by hand
|
||||
|
||||
That is not a fault. Tracking is a setting (Settings → Antenna), and with it off
|
||||
the antenna only moves when you tune it yourself.
|
||||
|
||||
If the antenna genuinely stops answering, the log says so and reconnects on its
|
||||
own:
|
||||
|
||||
steppir: status query failed, reconnecting: read status: timed out …
|
||||
steppir: reconnected after 2 failed attempt(s)
|
||||
|
||||
## A rotator turns the wrong way
|
||||
|
||||
On a **SPID**, check the dialect first: **Rot1Prog** for the azimuth-only
|
||||
controllers (RAK, RAU) and **Rot2Prog** for the RAS / BIG-RAS / MD-01 / MD-02.
|
||||
They use different frame lengths, and the wrong one is the usual cause of a
|
||||
heading that reads correctly while every commanded move goes the long way round.
|
||||
|
||||
Test with a **small** move first — from 45° to 50°, not to 180° — so a wrong
|
||||
result cannot wrap the coax at the top of the tower.
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
- [[Contest Logging]]
|
||||
- [[Net Control]]
|
||||
- [[Multi-Operator Live Status]]
|
||||
- [[Connections]]
|
||||
|
||||
**QSL & Awards**
|
||||
- [[Awards]]
|
||||
|
||||
Reference in New Issue
Block a user