Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -52,6 +52,7 @@ import (
|
||||
"hamlog/internal/powergenius"
|
||||
"hamlog/internal/profile"
|
||||
"hamlog/internal/pskr"
|
||||
"hamlog/internal/psu"
|
||||
"hamlog/internal/qslcard"
|
||||
"hamlog/internal/qso"
|
||||
"hamlog/internal/relaydev"
|
||||
@@ -66,6 +67,7 @@ import (
|
||||
"hamlog/internal/solar"
|
||||
"hamlog/internal/spe"
|
||||
"hamlog/internal/steppir"
|
||||
"hamlog/internal/syncfolder"
|
||||
"hamlog/internal/tunergenius"
|
||||
"hamlog/internal/uls"
|
||||
"hamlog/internal/ultrabeam"
|
||||
@@ -690,6 +692,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 +736,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 +772,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 +1170,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 +1426,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
|
||||
@@ -2781,6 +2794,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 +4365,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 +4415,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
|
||||
}
|
||||
|
||||
@@ -5974,6 +6029,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 +6123,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 +6194,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 +7145,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 +7163,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 +7212,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
|
||||
@@ -8771,7 +8840,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.
|
||||
@@ -10549,6 +10622,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"`
|
||||
@@ -14147,6 +14280,7 @@ func (a *App) reloadAfterProfileSwitch() {
|
||||
a.restartAsync("antenna", a.startUltrabeam)
|
||||
a.restartAsync("antgenius", a.startAntGenius)
|
||||
a.restartAsync("tuner", a.startTunerGenius)
|
||||
a.restartAsync("psu", a.startPSU)
|
||||
a.startQSORecorderIfEnabled()
|
||||
}
|
||||
|
||||
@@ -18407,3 +18541,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")
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,56 @@
|
||||
[
|
||||
{
|
||||
"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": "",
|
||||
|
||||
+114
-34
@@ -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);
|
||||
@@ -1770,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);
|
||||
@@ -2227,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
|
||||
@@ -2363,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(() => {
|
||||
@@ -3583,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'); }
|
||||
@@ -3709,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); });
|
||||
@@ -3818,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
|
||||
@@ -3872,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).
|
||||
@@ -5252,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)} />
|
||||
@@ -5282,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),
|
||||
}} />
|
||||
@@ -5306,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)}
|
||||
@@ -5316,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>
|
||||
);
|
||||
@@ -5444,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"
|
||||
>
|
||||
@@ -6234,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>
|
||||
@@ -6622,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)}
|
||||
@@ -6634,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">
|
||||
@@ -6902,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)} />
|
||||
@@ -6999,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),
|
||||
}} />
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
@@ -3079,7 +3229,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 +3545,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 +4859,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 +5778,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 +5813,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 +5853,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 +6343,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 +6540,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 +6558,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 +6586,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">
|
||||
|
||||
@@ -7,22 +7,77 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
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[];
|
||||
@@ -457,6 +512,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 +548,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`.
|
||||
@@ -585,7 +668,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} /> });
|
||||
@@ -594,6 +677,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; };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+26
-19
File diff suppressed because one or more lines are too long
@@ -0,0 +1,87 @@
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
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);
|
||||
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.5';
|
||||
export const APP_VERSION = '0.25.7';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+25
-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>;
|
||||
@@ -928,6 +941,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 +957,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 +1039,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 +1103,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 +1159,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']();
|
||||
}
|
||||
@@ -1798,6 +1822,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 +1854,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 +2018,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 +2146,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 +2258,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"];
|
||||
@@ -2463,6 +2465,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 +2811,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;
|
||||
@@ -4063,6 +4159,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 +4624,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 +4766,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+77
-2
@@ -48,6 +48,19 @@ type TCI struct {
|
||||
tx 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
|
||||
@@ -100,14 +113,65 @@ func (t *TCI) Connect() error {
|
||||
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 +180,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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -423,6 +423,25 @@ 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
|
||||
@@ -454,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 {
|
||||
@@ -598,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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -452,13 +456,13 @@ func (c *Cache) Get(ctx context.Context, callsign string) (Result, bool) {
|
||||
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 +470,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 +481,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 +505,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)
|
||||
@@ -506,7 +522,7 @@ func (c *Cache) Put(ctx context.Context, r Result) error {
|
||||
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 +535,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 +546,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")
|
||||
}
|
||||
}
|
||||
@@ -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,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,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,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,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.5"
|
||||
appVersion = "0.25.7"
|
||||
|
||||
// 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