Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2007479da6 | ||
|
|
4ba5569ca8 | ||
|
|
dc0b00b474 | ||
|
|
2695747db6 | ||
|
|
eba54344b0 | ||
|
|
93879a0ce1 | ||
|
|
8fccfa29b1 | ||
|
|
784d809290 | ||
|
|
700b688fc0 | ||
|
|
03598cfaff | ||
|
|
ed517eb8d3 | ||
|
|
1cfba19ed2 | ||
|
|
b18f5c491e | ||
|
|
741364bf99 | ||
|
|
d0f06d9670 | ||
|
|
8e49d37cbd | ||
|
|
b2382a6135 | ||
|
|
f8fb57210f | ||
|
|
8762131248 | ||
|
|
e5f79b18c2 | ||
|
|
1605e30f60 | ||
|
|
6aff322be6 | ||
|
|
ed3c132f70 | ||
|
|
385e8c4c86 | ||
|
|
61e5736f1e | ||
|
|
6ca808a6c9 | ||
|
|
f698096f53 | ||
|
|
ef628e066a | ||
|
|
4ffdfc2548 | ||
|
|
83e7727aab | ||
|
|
1348dc2177 | ||
|
|
57ac382f91 | ||
|
|
858c04d267 | ||
|
|
2484cd2515 | ||
|
|
62256942a1 | ||
|
|
3c8aadf41f | ||
|
|
2f5fd63afd | ||
|
|
4f3c3edaee | ||
|
|
7d33379fe1 | ||
|
|
82a49150d2 | ||
|
|
1ef9e553f7 | ||
|
|
39fab68bd4 |
@@ -41,6 +41,7 @@ import (
|
||||
"hamlog/internal/email"
|
||||
"hamlog/internal/extsvc"
|
||||
"hamlog/internal/geo"
|
||||
"hamlog/internal/gridcache"
|
||||
"hamlog/internal/integrations/udp"
|
||||
"hamlog/internal/lookup"
|
||||
"hamlog/internal/lotwusers"
|
||||
@@ -77,6 +78,7 @@ import (
|
||||
const (
|
||||
keyQRZUser = "lookup.qrz.user"
|
||||
keyQRZPassword = "lookup.qrz.password"
|
||||
keyQRZNickname = "lookup.qrz.prefer_nickname" // "1" → QRZ <nickname> over first+last name
|
||||
keyHQUser = "lookup.hamqth.user"
|
||||
keyHQPassword = "lookup.hamqth.password"
|
||||
keyCacheTTL = "lookup.cache.ttl_days"
|
||||
@@ -231,10 +233,12 @@ const (
|
||||
keyUltrabeamEnabled = "ultrabeam.enabled"
|
||||
keyUltrabeamHost = "ultrabeam.host"
|
||||
keyUltrabeamPort = "ultrabeam.port"
|
||||
keyUltrabeamFollow = "ultrabeam.follow" // "1" → re-tune to the rig frequency
|
||||
keyUltrabeamStep = "ultrabeam.step_khz" // re-tune hysteresis: 25 | 50 | 100 kHz
|
||||
keyMotorTrackMode = "motor.track_mode" // "always" | "step" | "band"
|
||||
keyMotorBandFreqs = "motor.band_freqs" // per-band tune frequency: "40m=7100,20m=14150"
|
||||
keyUltrabeamFollow = "ultrabeam.follow" // "1" → re-tune to the rig frequency
|
||||
keyUltrabeamStep = "ultrabeam.step_khz" // re-tune hysteresis: 25 | 50 | 100 kHz
|
||||
keyMotorTrackMode = "motor.track_mode" // "always" | "step" | "band"
|
||||
keyMotorBandFreqs = "motor.band_freqs" // per-band tune frequency: "40m=7100,20m=14150"
|
||||
keyChaseNewGrids = "cluster.chase_grids" // "1" → persist learnt locators across restarts
|
||||
keyRowColors = "appearance.row_colors"
|
||||
keyMotorType = "ultrabeam.type" // "ultrabeam" | "steppir" (default ultrabeam)
|
||||
keyMotorTransport = "ultrabeam.transport" // "tcp" | "serial" (default tcp)
|
||||
keyMotorCOM = "ultrabeam.com" // serial device name (COM3, /dev/ttyUSB0)
|
||||
@@ -295,6 +299,7 @@ const (
|
||||
keyClusterAutoConnect = "cluster.auto_connect" // open every enabled server at app start
|
||||
keyClusterSelfSpot = "cluster.self_spot" // "1" → announce ourselves on the cluster as we log
|
||||
keyClusterSelfSpotMin = "cluster.self_spot_minutes" // shortest gap between two self-spots
|
||||
keyClusterSpotTTL = "cluster.spot_ttl_min" // drop spots older than this; 0 = keep them
|
||||
|
||||
keyScpEnabled = "scp.enabled" // Super Check Partial / N+1 suggestions on
|
||||
|
||||
@@ -349,6 +354,7 @@ const (
|
||||
keyExtHRDLogCode = "extsvc.hrdlog.code" // HRDLog account upload code
|
||||
keyExtHRDLogAutoUpload = "extsvc.hrdlog.auto_upload"
|
||||
keyExtHRDLogUploadMode = "extsvc.hrdlog.upload_mode"
|
||||
keyExtHRDLogOnAir = "extsvc.hrdlog.on_air" // publish frequency/mode/rig live on hrdlog.net
|
||||
|
||||
keyExtEQSLUsername = "extsvc.eqsl.username"
|
||||
keyExtEQSLPassword = "extsvc.eqsl.password"
|
||||
@@ -560,7 +566,11 @@ type LookupSettings struct {
|
||||
Primary string `json:"primary"`
|
||||
Failsafe string `json:"failsafe"`
|
||||
DownloadImages bool `json:"download_images"` // show QRZ profile pictures in the UI
|
||||
CacheTTLDays int `json:"cache_ttl_days"`
|
||||
// QRZPreferNickname logs the name an operator goes BY rather than their
|
||||
// registered first+last. QRZ only: HamQTH's <nick> already serves that role
|
||||
// there, so the option would be a second switch for a behaviour it has.
|
||||
QRZPreferNickname bool `json:"qrz_prefer_nickname"`
|
||||
CacheTTLDays int `json:"cache_ttl_days"`
|
||||
}
|
||||
|
||||
// App is the application context bound to the Wails runtime.
|
||||
@@ -601,6 +611,14 @@ type App struct {
|
||||
// or when a setting that shapes the maps flips.
|
||||
clusterStatusIdx *clusterStatusCache
|
||||
clusterStatusMu sync.Mutex
|
||||
// qsoNumbers maps a QSO id to its chronological position, oldest = 1. Built
|
||||
// on demand from one ordered id query and dropped whenever the log changes,
|
||||
// alongside the other derived indexes.
|
||||
qsoNumbers map[int64]int
|
||||
// qsoNumMax is the date of the newest contact the map has numbered, so a QSO
|
||||
// logged now can be appended instead of forcing a rebuild.
|
||||
qsoNumMax time.Time
|
||||
qsoNumMu sync.Mutex
|
||||
// decodeGrids maps a callsign to the 4-character grid it announced in a CQ
|
||||
// heard over the WSJT-X UDP link. It is the ONLY source of grids we have for
|
||||
// a spot: a DX-cluster line carries the spotter's grid at best, never the
|
||||
@@ -611,8 +629,23 @@ type App struct {
|
||||
//
|
||||
// In memory only, and bounded: it is a session-local view of who is on the
|
||||
// air now, not a database.
|
||||
decodeGrids map[string]string
|
||||
decodeGridsMu sync.RWMutex
|
||||
//
|
||||
// Bounded in TWO generations. The cap used to drop the whole map, which was
|
||||
// survivable while the only source was this station's own decodes — it never
|
||||
// reached the ceiling. It is a cliff for any larger feed: every locator in the
|
||||
// list would vanish at once, periodically. Keeping the previous generation
|
||||
// means a rotation costs the older half and nothing more, and it needs no
|
||||
// insertion order, no per-entry timestamp and no bookkeeping on the write
|
||||
// path — which "evict the oldest thousand" would all require.
|
||||
decodeGrids map[string]string // current generation, written to
|
||||
decodeGridsOld map[string]string // previous generation, still readable
|
||||
decodeGridsMu sync.RWMutex
|
||||
// gridStore persists the map across restarts when grid chasing is on. With
|
||||
// it, rotation is switched OFF: rotating would drop callsigns the database
|
||||
// still holds, and a lookup would then miss something we know. The store
|
||||
// bounds itself by age instead, so memory follows how many distinct stations
|
||||
// have actually been heard in two years rather than a made-up ceiling.
|
||||
gridStore *gridcache.Store
|
||||
// pskr is the PSK Reporter MQTT feed, up only while the opening watch is on.
|
||||
// It is the source that makes VHF detection work at all: the cluster and RBN
|
||||
// carry a handful of 6 m spots where PSK Reporter carries hundreds.
|
||||
@@ -1373,10 +1406,14 @@ func (a *App) startup(ctx context.Context) {
|
||||
// Anonymous usage heartbeat (once/day) so we can gauge active users. No-op
|
||||
// when disabled in Preferences or until the PostHog key is configured.
|
||||
go a.sendTelemetryHeartbeat()
|
||||
go a.liveStatusLoop() // multi-op: heartbeat current activity to shared MySQL
|
||||
go a.chatLoop() // multi-op: poll the shared chat + heartbeat presence
|
||||
// PSK Reporter, when the opening watch is on. After the operator's grid is
|
||||
// known: without it there is no distance to measure and the feed stays down.
|
||||
go a.liveStatusLoop() // multi-op: heartbeat current activity to shared MySQL
|
||||
go a.chatLoop() // multi-op: poll the shared chat + heartbeat presence
|
||||
go a.hrdlogOnAirLoop() // publish frequency/mode/rig on hrdlog.net when enabled
|
||||
// Locator store BEFORE the feed: the feed asks whether it exists to decide
|
||||
// which bands to subscribe to.
|
||||
a.startGridCache()
|
||||
// PSK Reporter. After the operator's grid is known: without it there is no
|
||||
// distance to measure and no receiver squares to filter on, so it stays down.
|
||||
a.startBandOpenFeed()
|
||||
// One-time tidy-up of a field nothing used to record. Background, once.
|
||||
a.backfillDistancesOnce()
|
||||
@@ -1625,6 +1662,14 @@ func (a *App) shutdown(ctx context.Context) {
|
||||
if a.qsoRec != nil {
|
||||
a.qsoRec.Stop()
|
||||
}
|
||||
// Before the databases: Close flushes what the last minute learnt, and a
|
||||
// restart is exactly when the grid cache is worth the most.
|
||||
if a.gridStore != nil {
|
||||
if err := a.gridStore.Close(); err != nil {
|
||||
applog.Printf("gridcache: close: %v", err)
|
||||
}
|
||||
a.gridStore = nil
|
||||
}
|
||||
if a.logDb != nil && a.logDb != a.db {
|
||||
_ = a.logDb.Close() // shared MySQL logbook (separate from the local config DB)
|
||||
}
|
||||
@@ -2558,7 +2603,7 @@ func (a *App) reloadLookupProviders() {
|
||||
return
|
||||
}
|
||||
m, err := a.settings.GetMany(a.ctx,
|
||||
keyQRZUser, keyQRZPassword, keyHQUser, keyHQPassword,
|
||||
keyQRZUser, keyQRZPassword, keyQRZNickname, keyHQUser, keyHQPassword,
|
||||
keyCacheTTL, keyLookupPrimary, keyLookupFailsafe)
|
||||
if err != nil {
|
||||
fmt.Println("OpsLog: settings load error:", err)
|
||||
@@ -2572,7 +2617,9 @@ func (a *App) reloadLookupProviders() {
|
||||
switch name {
|
||||
case "qrz":
|
||||
if m[keyQRZUser] != "" && m[keyQRZPassword] != "" {
|
||||
return lookup.NewQRZ(m[keyQRZUser], m[keyQRZPassword])
|
||||
p := lookup.NewQRZ(m[keyQRZUser], m[keyQRZPassword])
|
||||
p.PreferNickname = m[keyQRZNickname] == "1"
|
||||
return p
|
||||
}
|
||||
case "hamqth":
|
||||
if m[keyHQUser] != "" && m[keyHQPassword] != "" {
|
||||
@@ -2682,6 +2729,9 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
|
||||
a.clusterStatusMu.Lock()
|
||||
a.clusterStatusIdx = nil
|
||||
a.clusterStatusMu.Unlock()
|
||||
// Give the contact its number without rereading the log — same reason as
|
||||
// above, a contest run must not pay a full scan per QSO.
|
||||
a.noteQSONumbered(id, q.QSODate)
|
||||
// Announce the log RIGHT AWAY so the grid/UI refresh at once and the entry
|
||||
// form clears immediately — the operator is not made to wait on the DB.
|
||||
wruntime.EventsEmit(a.ctx, "qso:logged", id)
|
||||
@@ -4440,6 +4490,11 @@ func (a *App) invalidateAwardStats() {
|
||||
a.clusterStatusMu.Lock()
|
||||
a.clusterStatusIdx = nil
|
||||
a.clusterStatusMu.Unlock()
|
||||
// The numbering shifts whenever a contact is added or removed — and an
|
||||
// imported ADIF inserts into the MIDDLE of the order, not at the end.
|
||||
a.qsoNumMu.Lock()
|
||||
a.qsoNumbers = nil
|
||||
a.qsoNumMu.Unlock()
|
||||
// Bulk QSO changes (import, delete, bulk edit) also land here — refresh the
|
||||
// worked-index so alert "needed" checks stay accurate. Async: never block the
|
||||
// mutation, and it's a single lightweight query.
|
||||
@@ -5763,7 +5818,96 @@ func (a *App) ListQSOFiltered(f qso.QueryFilter) ([]qso.QSO, error) {
|
||||
if a.qso == nil {
|
||||
return nil, fmt.Errorf("db not initialized")
|
||||
}
|
||||
return a.qso.ListFiltered(a.ctx, f)
|
||||
list, err := a.qso.ListFiltered(a.ctx, f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.stampQSONumbers(list)
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// stampQSONumbers fills each QSO's position in the log, oldest = 1.
|
||||
//
|
||||
// Stamped here rather than computed in the query because the number has to be
|
||||
// the rank in the WHOLE log: ranking inside the result would renumber every
|
||||
// contact the moment a filter is applied, and the operator would see QSO #1
|
||||
// change identity as they typed.
|
||||
func (a *App) stampQSONumbers(list []qso.QSO) {
|
||||
idx := a.qsoNumberIndex()
|
||||
if idx == nil {
|
||||
return
|
||||
}
|
||||
for i := range list {
|
||||
list[i].Number = idx[list[i].ID]
|
||||
}
|
||||
}
|
||||
|
||||
// countQSOForNumbering is Count with the nil-repo guard the numbering needs —
|
||||
// it runs before the database is up on a fresh start.
|
||||
func (a *App) countQSOForNumbering() (int64, error) {
|
||||
if a.qso == nil {
|
||||
return 0, fmt.Errorf("db not initialized")
|
||||
}
|
||||
return a.qso.Count(a.ctx)
|
||||
}
|
||||
|
||||
// qsoNumberIndex returns id → chronological position, building it once and
|
||||
// keeping it until the log changes (invalidateAwardStats drops it).
|
||||
func (a *App) qsoNumberIndex() map[int64]int {
|
||||
a.qsoNumMu.Lock()
|
||||
defer a.qsoNumMu.Unlock()
|
||||
if a.qsoNumbers != nil {
|
||||
// Cheap consistency check rather than trusting every insert path to have
|
||||
// remembered to invalidate. Several bulk paths — the POTA hunter import,
|
||||
// the LoTW and QRZ "add contacts I was missing" passes — insert straight
|
||||
// through the repo, and they add contacts with OLD dates, which lands them
|
||||
// in the MIDDLE of the order and shifts every number after them. A stale
|
||||
// map there is not merely incomplete, it is wrong.
|
||||
//
|
||||
// One indexed COUNT against a map length, versus rereading every id.
|
||||
if n, err := a.countQSOForNumbering(); err == nil && int(n) != len(a.qsoNumbers) {
|
||||
a.qsoNumbers = nil
|
||||
} else {
|
||||
return a.qsoNumbers
|
||||
}
|
||||
}
|
||||
if a.qso == nil {
|
||||
return nil
|
||||
}
|
||||
ids, newest, err := a.qso.OrderedIDs(a.ctx)
|
||||
if err != nil {
|
||||
applog.Printf("qso numbering: %v — the column will be empty", err)
|
||||
return nil
|
||||
}
|
||||
m := make(map[int64]int, len(ids))
|
||||
for i, id := range ids {
|
||||
m[id] = i + 1
|
||||
}
|
||||
a.qsoNumbers = m
|
||||
a.qsoNumMax = newest
|
||||
return m
|
||||
}
|
||||
|
||||
// noteQSONumbered keeps the numbering current for a contact just logged.
|
||||
//
|
||||
// A QSO logged from the entry form is the newest there is, so it simply takes
|
||||
// the next number — a full rebuild would read every id in the log, which during
|
||||
// a contest run would cost more than the three queries the grid refresh already
|
||||
// makes. A contact entered with an OLDER date belongs in the middle of the
|
||||
// order, so there the map is dropped and rebuilt correctly rather than
|
||||
// mis-numbered.
|
||||
func (a *App) noteQSONumbered(id int64, at time.Time) {
|
||||
a.qsoNumMu.Lock()
|
||||
defer a.qsoNumMu.Unlock()
|
||||
if a.qsoNumbers == nil {
|
||||
return // nothing built yet; the lazy build will see this QSO anyway
|
||||
}
|
||||
if at.Before(a.qsoNumMax) {
|
||||
a.qsoNumbers = nil
|
||||
return
|
||||
}
|
||||
a.qsoNumbers[id] = len(a.qsoNumbers) + 1
|
||||
a.qsoNumMax = at
|
||||
}
|
||||
|
||||
// CountQSOFiltered returns how many QSOs match the filter (ignoring the row
|
||||
@@ -7063,7 +7207,7 @@ func (a *App) GetLookupSettings() (LookupSettings, error) {
|
||||
}
|
||||
m, err := a.settings.GetMany(a.ctx,
|
||||
keyQRZUser, keyQRZPassword, keyHQUser, keyHQPassword,
|
||||
keyCacheTTL, keyLookupPrimary, keyLookupFailsafe, keyLookupImages)
|
||||
keyCacheTTL, keyLookupPrimary, keyLookupFailsafe, keyLookupImages, keyQRZNickname)
|
||||
if err != nil {
|
||||
return LookupSettings{}, err
|
||||
}
|
||||
@@ -7072,14 +7216,15 @@ func (a *App) GetLookupSettings() (LookupSettings, error) {
|
||||
ttl = 30
|
||||
}
|
||||
return LookupSettings{
|
||||
QRZUser: m[keyQRZUser],
|
||||
QRZPassword: m[keyQRZPassword],
|
||||
HamQTHUser: m[keyHQUser],
|
||||
HamQTHPassword: m[keyHQPassword],
|
||||
Primary: m[keyLookupPrimary],
|
||||
Failsafe: m[keyLookupFailsafe],
|
||||
DownloadImages: m[keyLookupImages] == "1",
|
||||
CacheTTLDays: ttl,
|
||||
QRZUser: m[keyQRZUser],
|
||||
QRZPassword: m[keyQRZPassword],
|
||||
HamQTHUser: m[keyHQUser],
|
||||
HamQTHPassword: m[keyHQPassword],
|
||||
Primary: m[keyLookupPrimary],
|
||||
Failsafe: m[keyLookupFailsafe],
|
||||
DownloadImages: m[keyLookupImages] == "1",
|
||||
QRZPreferNickname: m[keyQRZNickname] == "1",
|
||||
CacheTTLDays: ttl,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -7105,6 +7250,7 @@ func (a *App) SaveLookupSettings(s LookupSettings) error {
|
||||
keyLookupPrimary: s.Primary,
|
||||
keyLookupFailsafe: s.Failsafe,
|
||||
keyLookupImages: boolStr(s.DownloadImages),
|
||||
keyQRZNickname: boolStr(s.QRZPreferNickname),
|
||||
} {
|
||||
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||
return err
|
||||
@@ -9768,7 +9914,7 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
|
||||
keyExtLoTWUploadFlag, keyExtLoTWUploadFlags, keyExtLoTWWriteLog,
|
||||
keyExtLoTWAutoUpload, keyExtLoTWUploadMode,
|
||||
keyExtLoTWUsername, keyExtLoTWWebPassword,
|
||||
keyExtHRDLogCallsign, keyExtHRDLogCode, keyExtHRDLogAutoUpload, keyExtHRDLogUploadMode,
|
||||
keyExtHRDLogCallsign, keyExtHRDLogCode, keyExtHRDLogAutoUpload, keyExtHRDLogUploadMode, keyExtHRDLogOnAir,
|
||||
keyExtEQSLUsername, keyExtEQSLPassword, keyExtEQSLQTHNick, keyExtEQSLAutoUpload, keyExtEQSLUploadMode,
|
||||
keyExtCloudlogURL, keyExtCloudlogAPIKey, keyExtCloudlogStationID,
|
||||
keyExtCloudlogAutoUpload, keyExtCloudlogUploadMode, keyExtDeleteRemote)
|
||||
@@ -9822,6 +9968,7 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
|
||||
Code: m[keyExtHRDLogCode],
|
||||
AutoUpload: m[keyExtHRDLogAutoUpload] == "1",
|
||||
UploadMode: extsvc.UploadMode(m[keyExtHRDLogUploadMode]),
|
||||
OnAir: m[keyExtHRDLogOnAir] == "1",
|
||||
}
|
||||
// Default the HRDLog callsign to the active profile's call when unset.
|
||||
if out.HRDLog.Callsign == "" && a.profiles != nil {
|
||||
@@ -9951,6 +10098,7 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
|
||||
keyExtHRDLogCode: strings.TrimSpace(cfg.HRDLog.Code),
|
||||
keyExtHRDLogAutoUpload: hlAuto,
|
||||
keyExtHRDLogUploadMode: hlMode,
|
||||
keyExtHRDLogOnAir: boolStr(cfg.HRDLog.OnAir),
|
||||
|
||||
keyExtEQSLUsername: strings.ToUpper(strings.TrimSpace(cfg.EQSL.Username)),
|
||||
keyExtEQSLPassword: cfg.EQSL.Password,
|
||||
@@ -10093,6 +10241,16 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
|
||||
uploaded++
|
||||
}
|
||||
emit(fmt.Sprintf("LoTW: %d QSO(s) uploaded", uploaded))
|
||||
// TQSL accepted the batch but left contacts out of it — already
|
||||
// uploaded, or outside the certificate's date range. Said out loud:
|
||||
// the operator has no other way to learn that some of what they just
|
||||
// selected is still not on LoTW.
|
||||
if res.Ignored {
|
||||
emit(res.Message)
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "toast", "LoTW: "+res.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if svc == extsvc.ServiceClublog || svc == extsvc.ServiceHRDLog {
|
||||
statusCol, dateCol := "clublog_qso_upload_status", "clublog_qso_upload_date"
|
||||
@@ -11736,6 +11894,21 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
|
||||
return 0, fmt.Errorf("record missing required fields (call/band/mode/date)")
|
||||
}
|
||||
|
||||
// The grid the station SENT beats any directory.
|
||||
//
|
||||
// A CQ in FT8 carries the operator's square, and it is where they are right
|
||||
// now. QRZ and HamQTH describe where they LIVE, which for an expedition is
|
||||
// the wrong side of the planet: RI0FA transmitted QN35 all evening and was
|
||||
// logged with its licence-holder's home square, because WSJT-X's ADIF carries
|
||||
// no GRIDSQUARE and the lookup was the only source left.
|
||||
//
|
||||
// Applied BEFORE the lookup, so refineGrid below sees a grid it must not
|
||||
// replace. It still upgrades a 4-character square to a 6-character one from
|
||||
// the same field.
|
||||
if g := a.lookupDecodeGrid(q.Callsign); g != "" {
|
||||
q.Grid = refineGrid(q.Grid, g)
|
||||
}
|
||||
|
||||
// ── Lookup-based enrichment ──
|
||||
// WSJT sends only call/freq/mode/RST/date. Fill Name/QTH/Country/
|
||||
// Grid/CQZ/ITUZ/DXCC/Continent via the lookup chain (QRZ/HamQTH/
|
||||
@@ -11879,6 +12052,10 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
|
||||
return 0, fmt.Errorf("insert qso: %w", err)
|
||||
}
|
||||
q.ID = id
|
||||
// Same as the manual path: give the contact its number without rereading the
|
||||
// log. This insert bypasses AddQSO entirely, which is why UDP-logged contacts
|
||||
// came out unnumbered while hand-logged ones did not.
|
||||
a.noteQSONumbered(id, q.QSODate)
|
||||
a.noteLiveQSO() // multi-op: flip this operator back "online" (publishes async)
|
||||
// Announce the log AT ONCE so the grid / ON-AIR badge / stations-on-air widget
|
||||
// refresh immediately, then run the DB-heavy enrichment off the critical path
|
||||
@@ -11937,15 +12114,7 @@ func (a *App) consumeUDPEvents() {
|
||||
// Remember the grid before anything else: a CQ is the one message that
|
||||
// carries it, and the station may never send another.
|
||||
if ev.DecodeGrid != "" {
|
||||
a.decodeGridsMu.Lock()
|
||||
if a.decodeGrids == nil {
|
||||
a.decodeGrids = make(map[string]string, 512)
|
||||
}
|
||||
if len(a.decodeGrids) > 20000 {
|
||||
a.decodeGrids = make(map[string]string, 512) // bound a long session
|
||||
}
|
||||
a.decodeGrids[strings.ToUpper(ev.DecodeCall)] = ev.DecodeGrid
|
||||
a.decodeGridsMu.Unlock()
|
||||
a.rememberDecodeGrid(ev.DecodeCall, ev.DecodeGrid, gridcache.SourceDecode)
|
||||
}
|
||||
// A WSJT-X decode (heard station). Render it on the FlexRadio
|
||||
// panadapter when the option is on; green + SNR comment, auto-expiring
|
||||
@@ -11978,7 +12147,13 @@ func (a *App) consumeUDPEvents() {
|
||||
})
|
||||
}
|
||||
case ev.LoggedADIF != "":
|
||||
applog.Printf("udp: emit udp:logged_qso (%d bytes ADIF)\n", len(ev.LoggedADIF))
|
||||
// The RECORD, not just its size. "Was the grid in what WSJT-X sent, or
|
||||
// did OpsLog put it there?" is the first question asked when a contact
|
||||
// is logged in the wrong square, and a byte count cannot answer it —
|
||||
// an expedition logged at its licence holder's home QTH stayed
|
||||
// undiagnosable for exactly that reason. One line per QSO, ~280 bytes.
|
||||
applog.Printf("udp: emit udp:logged_qso (%d bytes) %s\n",
|
||||
len(ev.LoggedADIF), strings.Join(strings.Fields(ev.LoggedADIF), " "))
|
||||
wruntime.EventsEmit(a.ctx, "udp:logged_qso", map[string]any{
|
||||
"config_id": ev.ConfigID,
|
||||
"service": string(ev.Service),
|
||||
@@ -14660,14 +14835,14 @@ func (a steppirAdapter) Status() motorStatus {
|
||||
// is kept (bindings + frontend) though it now covers SteppIR too.
|
||||
type UltrabeamSettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Type string `json:"type"` // "ultrabeam" | "steppir"
|
||||
Transport string `json:"transport"` // "tcp" | "serial"
|
||||
Host string `json:"host"` // tcp
|
||||
Port int `json:"port"` // tcp
|
||||
COM string `json:"com"` // serial device
|
||||
Baud int `json:"baud"` // serial baud
|
||||
Follow bool `json:"follow"` // re-tune the antenna to the rig's frequency
|
||||
StepKHz int `json:"step_khz"` // re-tune only when the freq moved this far (25/50/100)
|
||||
Type string `json:"type"` // "ultrabeam" | "steppir"
|
||||
Transport string `json:"transport"` // "tcp" | "serial"
|
||||
Host string `json:"host"` // tcp
|
||||
Port int `json:"port"` // tcp
|
||||
COM string `json:"com"` // serial device
|
||||
Baud int `json:"baud"` // serial baud
|
||||
Follow bool `json:"follow"` // re-tune the antenna to the rig's frequency
|
||||
StepKHz int `json:"step_khz"` // re-tune only when the freq moved this far (25/50/100)
|
||||
// When the follow loop is allowed to move the motors. The three choices the
|
||||
// SteppIR's own controller software offers, because operators arrive with
|
||||
// that mental model:
|
||||
@@ -17014,6 +17189,151 @@ func (a *App) clusterStatusMaps() *clusterStatusCache {
|
||||
// was ambiguous and the frontend couldn't infer) we degrade gracefully
|
||||
// to band-only — saying "worked" rather than wrongly flagging "new-slot"
|
||||
// just because we don't know the mode.
|
||||
// decodeGridsCap is how many callsigns one generation holds before it rotates.
|
||||
//
|
||||
// 100 000 measured at 82 bytes an entry — 8 MB a generation, 16 MB for both,
|
||||
// which is nothing next to what it buys: a locator on a spot instead of an empty
|
||||
// column. The old 20 000 was chosen when the only feed was this station's own
|
||||
// decodes and the ceiling was never reached anyway.
|
||||
const decodeGridsCap = 100000
|
||||
|
||||
// GetChaseNewGrids reports whether learnt locators are kept across restarts.
|
||||
func (a *App) GetChaseNewGrids() bool { return a.settingOr(keyChaseNewGrids, "") == "1" }
|
||||
|
||||
// GridCacheStatus is the live count under the option. A store that is on but
|
||||
// has learnt nothing looks exactly like a broken one until a number moves.
|
||||
type GridCacheStatus struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Known int `json:"known"` // locators in memory, stored + learnt this session
|
||||
Pending int `json:"pending"` // waiting for the next batch write
|
||||
}
|
||||
|
||||
// GetGridCacheStatus reports what the locator store holds.
|
||||
func (a *App) GetGridCacheStatus() GridCacheStatus {
|
||||
out := GridCacheStatus{Enabled: a.gridStore != nil}
|
||||
a.decodeGridsMu.RLock()
|
||||
out.Known = len(a.decodeGrids) + len(a.decodeGridsOld)
|
||||
a.decodeGridsMu.RUnlock()
|
||||
if a.gridStore != nil {
|
||||
out.Pending = a.gridStore.Pending()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SetChaseNewGrids turns grid chasing on or off and applies it immediately.
|
||||
func (a *App) SetChaseNewGrids(on bool) error {
|
||||
a.setSetting(keyChaseNewGrids, boolStr(on))
|
||||
a.startGridCache()
|
||||
// Resubscribe: with grid chasing the feed covers every band, without it only
|
||||
// the opening bands — and with neither consumer it comes down entirely.
|
||||
a.startBandOpenFeed()
|
||||
return nil
|
||||
}
|
||||
|
||||
// startGridCache brings the locator store up or down to match the setting.
|
||||
//
|
||||
// Switching it ON seeds the in-memory map from the database, which is the whole
|
||||
// point: the cluster's locator column is populated in the first second instead
|
||||
// of after an hour of listening. Switching it OFF closes the file and leaves the
|
||||
// map alone — what has already been learnt this session stays usable, it simply
|
||||
// stops being remembered. Nothing is deleted: turning the option back on picks
|
||||
// up where it left off.
|
||||
func (a *App) startGridCache() {
|
||||
if a.gridStore != nil {
|
||||
if err := a.gridStore.Close(); err != nil {
|
||||
applog.Printf("gridcache: close: %v", err)
|
||||
}
|
||||
a.gridStore = nil
|
||||
}
|
||||
if !a.GetChaseNewGrids() {
|
||||
return
|
||||
}
|
||||
path := filepath.Join(a.dataDir, "grids.db")
|
||||
st, err := gridcache.Open(path, applog.Printf)
|
||||
if err != nil {
|
||||
applog.Printf("gridcache: disabled — %v", err)
|
||||
return
|
||||
}
|
||||
seed, err := st.LoadAll()
|
||||
if err != nil {
|
||||
applog.Printf("gridcache: cannot read %s (%v) — starting empty", path, err)
|
||||
seed = map[string]string{}
|
||||
}
|
||||
a.decodeGridsMu.Lock()
|
||||
if a.decodeGrids == nil {
|
||||
a.decodeGrids = make(map[string]string, len(seed)+512)
|
||||
}
|
||||
// Seed UNDER what this session already heard: a locator decoded a minute ago
|
||||
// is newer than one stored days back, and the newest report is the one that
|
||||
// counts when a station has moved.
|
||||
for call, grid := range seed {
|
||||
if _, live := a.decodeGrids[call]; !live {
|
||||
a.decodeGrids[call] = grid
|
||||
}
|
||||
}
|
||||
n := len(a.decodeGrids)
|
||||
a.decodeGridsMu.Unlock()
|
||||
|
||||
a.gridStore = st
|
||||
st.Start(a.ctx)
|
||||
applog.Printf("gridcache: grid chasing on — %d locators loaded from %s (%d known in total)", len(seed), path, n)
|
||||
}
|
||||
|
||||
// rememberDecodeGrid records the grid a station announced.
|
||||
//
|
||||
// Rotation, not eviction: when the current generation fills it becomes the
|
||||
// previous one and a fresh map takes over. Nothing is scanned, nothing is
|
||||
// timestamped, and the write stays a single map assignment — which matters
|
||||
// because this runs once per decode.
|
||||
func (a *App) rememberDecodeGrid(call, grid, source string) {
|
||||
call = strings.ToUpper(strings.TrimSpace(call))
|
||||
grid = strings.TrimSpace(grid)
|
||||
if call == "" || grid == "" {
|
||||
return
|
||||
}
|
||||
store := a.gridStore
|
||||
|
||||
a.decodeGridsMu.Lock()
|
||||
if a.decodeGrids == nil {
|
||||
a.decodeGrids = make(map[string]string, 512)
|
||||
}
|
||||
// Rotate only while nothing is persisting the map. With a store the cap would
|
||||
// discard callsigns the database still holds, so the lookup would miss what
|
||||
// we know; age in the store is the bound instead.
|
||||
if store == nil && len(a.decodeGrids) >= decodeGridsCap {
|
||||
a.decodeGridsOld = a.decodeGrids
|
||||
a.decodeGrids = make(map[string]string, 512)
|
||||
}
|
||||
// Only a CHANGE is worth writing. The feeds repeat themselves — the same
|
||||
// station is reported by dozens of receivers a minute — so queueing every
|
||||
// report would put the whole stream in the batch instead of the news in it.
|
||||
changed := a.decodeGrids[call] != grid
|
||||
if changed && a.decodeGridsOld != nil && a.decodeGridsOld[call] == grid {
|
||||
// Known already, just in the older generation: promote it without calling
|
||||
// it news.
|
||||
changed = false
|
||||
}
|
||||
a.decodeGrids[call] = grid
|
||||
a.decodeGridsMu.Unlock()
|
||||
|
||||
if changed && store != nil {
|
||||
store.Put(call, grid, source)
|
||||
}
|
||||
}
|
||||
|
||||
// lookupDecodeGrid returns the last grid heard for a callsign, "" if unknown.
|
||||
// The previous generation is consulted second, so a station that has gone quiet
|
||||
// keeps its locator across one rotation instead of losing it at the cliff.
|
||||
func (a *App) lookupDecodeGrid(call string) string {
|
||||
call = strings.ToUpper(call)
|
||||
a.decodeGridsMu.RLock()
|
||||
defer a.decodeGridsMu.RUnlock()
|
||||
if g := a.decodeGrids[call]; g != "" {
|
||||
return g
|
||||
}
|
||||
return a.decodeGridsOld[call]
|
||||
}
|
||||
|
||||
func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
|
||||
out := make([]SpotStatus, len(spots))
|
||||
if a.qso == nil {
|
||||
@@ -17102,12 +17422,10 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
|
||||
// is part of the key, so the "group digital modes" option decides whether a
|
||||
// grid worked on FT8 still counts as new on FT4 — one rule, no branch here.
|
||||
{
|
||||
// The length check has to be INSIDE the lock: the decode goroutine
|
||||
// replaces this map wholesale when it grows too large, so reading len()
|
||||
// unguarded is a race on the map header, not a cheap fast path.
|
||||
a.decodeGridsMu.RLock()
|
||||
g := a.decodeGrids[strings.ToUpper(q.Call)]
|
||||
a.decodeGridsMu.RUnlock()
|
||||
// Read through the accessor: the decode goroutine swaps these maps on
|
||||
// rotation, so touching them unguarded is a race on the map header,
|
||||
// not a cheap fast path.
|
||||
g := a.lookupDecodeGrid(q.Call)
|
||||
if g != "" {
|
||||
out[i].Grid = g
|
||||
cm := out[i].Mode
|
||||
@@ -17134,6 +17452,16 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
|
||||
if a.dxcc == nil {
|
||||
continue
|
||||
}
|
||||
// Say WHY a maritime/aeronautical mobile has no entity, rather than
|
||||
// leaving the column blank — blank is what an unresolved spot looks like,
|
||||
// and the two mean opposite things: one is "we don't know yet", this is
|
||||
// "there is nothing to know, the station is at sea". Display only: the
|
||||
// QSO's own Country stays empty, because it is a DXCC entity name and
|
||||
// there is none.
|
||||
if dxcc.IsMobileNoEntity(q.Call) {
|
||||
out[i].Country = "Maritime mobile — no DXCC"
|
||||
continue
|
||||
}
|
||||
m, ok := a.dxcc.Lookup(q.Call)
|
||||
if !ok || m.Entity == nil {
|
||||
continue
|
||||
@@ -17424,3 +17752,61 @@ func (a *App) SetKenwoodKeySpeed(wpm int) error {
|
||||
}
|
||||
return a.cat.KenwoodDo(func(k cat.KenwoodController) error { return k.SetKeySpeed(wpm) })
|
||||
}
|
||||
|
||||
// spotTTLMax bounds the setting. Twelve hours is far past any operational use
|
||||
// and exists only so a mistyped value cannot mean "never".
|
||||
const spotTTLMax = 720
|
||||
|
||||
// GetSpotTTLMinutes returns how long a spot stays in the list, in minutes.
|
||||
// 0 means spots are kept until the count cap pushes them out, which is what
|
||||
// OpsLog always did.
|
||||
func (a *App) GetSpotTTLMinutes() int {
|
||||
n, _ := strconv.Atoi(a.settingOr(keyClusterSpotTTL, ""))
|
||||
if n < 0 {
|
||||
return 0
|
||||
}
|
||||
if n > spotTTLMax {
|
||||
return spotTTLMax
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// SetSpotTTLMinutes sets it. Clamped here as well as in the UI: the value
|
||||
// shapes what an operator sees on the band map, and a stale or bypassed
|
||||
// frontend must not be able to widen it past the ceiling.
|
||||
func (a *App) SetSpotTTLMinutes(min int) error {
|
||||
if min < 0 {
|
||||
min = 0
|
||||
}
|
||||
if min > spotTTLMax {
|
||||
min = spotTTLMax
|
||||
}
|
||||
a.setSetting(keyClusterSpotTTL, strconv.Itoa(min))
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetCompactHeight resizes the compact window to fit its content.
|
||||
//
|
||||
// The height used to be a constant "tuned so the compact entry strip fits in a
|
||||
// single row". Constants tuned against a layout stop being true the moment the
|
||||
// layout changes, and this one outlived a strip that had since lost a row —
|
||||
// leaving a band of empty window under the fields. The frontend measures what
|
||||
// it actually rendered and says so.
|
||||
//
|
||||
// Ignored unless compact is on, so nothing can shrink the normal window.
|
||||
func (a *App) SetCompactHeight(h int) {
|
||||
if a.ctx == nil || !a.compact {
|
||||
return
|
||||
}
|
||||
// Bounded: a measurement of zero (a layout not yet painted) must not collapse
|
||||
// the window to nothing, and no strip is ever 600px tall.
|
||||
if h < 80 || h > 600 {
|
||||
return
|
||||
}
|
||||
w, _ := wruntime.WindowGetSize(a.ctx)
|
||||
if w <= 0 {
|
||||
w = compactW
|
||||
}
|
||||
wruntime.WindowSetMinSize(a.ctx, 900, h)
|
||||
wruntime.WindowSetSize(a.ctx, w, h)
|
||||
}
|
||||
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
package main
|
||||
|
||||
// Row colouring for the log grid, by QSL / LoTW status — the thing Logger32 does
|
||||
// and the reason an operator can tell at a glance what still needs sending.
|
||||
//
|
||||
// Rules are ORDERED and the first match wins, because a contact is usually
|
||||
// several things at once: one confirmed on LoTW and by card is confirmed, not
|
||||
// "sent, awaiting reply". Putting the order in the data rather than in a chain
|
||||
// of ifs is what lets the settings panel show it in the same order it applies.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// RowColorRule is one status and the colour it paints.
|
||||
type RowColorRule struct {
|
||||
ID string `json:"id"`
|
||||
Color string `json:"color"`
|
||||
Enabled bool `json:"enabled"`
|
||||
// Channels this rule looks at: qsl (paper), lotw, eqsl, qrz.
|
||||
//
|
||||
// Empty means ALL of them — what an operator expects from a rule they have
|
||||
// not narrowed, and what keeps a config written before this field meaningful.
|
||||
Channels []string `json:"channels"`
|
||||
}
|
||||
|
||||
// The QSL channels a rule can be scoped to.
|
||||
var rowColorChannels = []string{"qsl", "lotw", "eqsl", "qrz"}
|
||||
|
||||
// RowColorSettings is the whole appearance block.
|
||||
type RowColorSettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
// Style is how the colour is shown: "bar" paints a stripe down the left edge,
|
||||
// "tint" washes the row, "both" does each.
|
||||
//
|
||||
// A bar is the default because a filled row is a poor signal in a log where
|
||||
// nearly every contact has SOME QSL state: colour that is always present
|
||||
// stops being information and becomes a striped background, with the data
|
||||
// behind it. The stripe says the same thing and costs nothing to read.
|
||||
Style string `json:"style"`
|
||||
// Intensity is the tint strength in percent. Only used by "tint"/"both".
|
||||
Intensity int `json:"intensity"`
|
||||
// BandMapLotw marks stations that upload to LoTW on the band map, with the
|
||||
// same "L" badge the cluster list uses — one visual vocabulary across the two
|
||||
// views rather than a second invention.
|
||||
BandMapLotw bool `json:"bandmap_lotw"`
|
||||
// Configured distinguishes "saved with this off" from "saved before the
|
||||
// option existed", so a new marker can default ON without silently turning
|
||||
// itself back on for an operator who switched it off.
|
||||
Configured bool `json:"configured"`
|
||||
Rules []RowColorRule `json:"rules"`
|
||||
}
|
||||
|
||||
// The rule ids, in priority order. The frontend matches on these and holds the
|
||||
// labels, so a translated name never has to travel through the settings.
|
||||
// The four categories, in the order they are tested — first match wins.
|
||||
//
|
||||
// "To send" comes FIRST on purpose. A contact can be confirmed on LoTW and
|
||||
// still owe a paper card, and the colour an operator scans for is the one that
|
||||
// means "there is something left to do here". Put after "confirmed", that row
|
||||
// would go green and the card would never be printed.
|
||||
//
|
||||
// "Worked" is the catch-all: a contact with nothing sent, nothing asked for and
|
||||
// nothing back. Off by default — with it on, every remaining row is painted.
|
||||
var rowColorOrder = []string{
|
||||
"to_send", // requested or queued on a watched channel, not gone out
|
||||
"confirmed", // a watched channel has a confirmation back
|
||||
"sent", // gone out on a watched channel, nothing back yet
|
||||
"worked", // none of the above
|
||||
}
|
||||
|
||||
// Defaults: green for done, amber for waiting, blue for owed. Deliberately
|
||||
// muted — they are composited at low opacity over a dark grid, and a saturated
|
||||
// value there reads as an error state rather than a status.
|
||||
var rowColorDefaults = map[string]string{
|
||||
"to_send": "#a855f7",
|
||||
"confirmed": "#16a34a",
|
||||
"sent": "#f59e0b",
|
||||
"worked": "#64748b",
|
||||
}
|
||||
|
||||
// hexColor guards what reaches the stylesheet. The value is interpolated into a
|
||||
// CSS color-mix() by the grid, so anything that is not plainly a hex colour is
|
||||
// refused rather than passed through.
|
||||
var hexColor = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
|
||||
|
||||
func normRowColors(s RowColorSettings) RowColorSettings {
|
||||
byID := map[string]RowColorRule{}
|
||||
for _, r := range s.Rules {
|
||||
byID[r.ID] = r
|
||||
}
|
||||
out := RowColorSettings{
|
||||
Enabled: s.Enabled, Style: s.Style, Intensity: s.Intensity,
|
||||
BandMapLotw: s.BandMapLotw, Configured: true,
|
||||
}
|
||||
if !s.Configured {
|
||||
out.BandMapLotw = true // new option, on unless the operator says otherwise
|
||||
}
|
||||
switch out.Style {
|
||||
case "bar", "tint", "both":
|
||||
default:
|
||||
out.Style = "bar"
|
||||
}
|
||||
// Clamped rather than rejected: the value only shapes a colour-mix, and a
|
||||
// number outside the range is a slider that got away, not a fault worth
|
||||
// resetting the operator's whole choice for.
|
||||
if out.Intensity < 5 {
|
||||
out.Intensity = 12
|
||||
} else if out.Intensity > 45 {
|
||||
out.Intensity = 45
|
||||
}
|
||||
ok := map[string]bool{}
|
||||
for _, c := range rowColorChannels {
|
||||
ok[c] = true
|
||||
}
|
||||
for _, id := range rowColorOrder {
|
||||
r := byID[id]
|
||||
r.ID = id
|
||||
if !hexColor.MatchString(strings.TrimSpace(r.Color)) {
|
||||
r.Color = rowColorDefaults[id]
|
||||
}
|
||||
// Keep only channels we know, in the canonical order, de-duplicated.
|
||||
want := map[string]bool{}
|
||||
for _, c := range r.Channels {
|
||||
want[strings.ToLower(strings.TrimSpace(c))] = true
|
||||
}
|
||||
chans := []string{}
|
||||
for _, c := range rowColorChannels {
|
||||
if want[c] {
|
||||
chans = append(chans, c)
|
||||
}
|
||||
}
|
||||
r.Channels = chans
|
||||
out.Rules = append(out.Rules, r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetRowColors returns the row-colouring configuration, defaults included so the
|
||||
// panel never has to invent one.
|
||||
func (a *App) GetRowColors() RowColorSettings {
|
||||
var s RowColorSettings
|
||||
if raw := a.settingOr(keyRowColors, ""); raw != "" {
|
||||
_ = json.Unmarshal([]byte(raw), &s)
|
||||
}
|
||||
return normRowColors(s)
|
||||
}
|
||||
|
||||
// SaveRowColors persists it.
|
||||
func (a *App) SaveRowColors(s RowColorSettings) error {
|
||||
b, err := json.Marshal(normRowColors(s))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.setSetting(keyRowColors, string(b))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// The colour is interpolated into a CSS color-mix() by the grid, so anything
|
||||
// that is not plainly a hex colour has to be refused rather than passed on.
|
||||
func TestRowColorsRefuseAnythingButHex(t *testing.T) {
|
||||
in := RowColorSettings{Enabled: true, Rules: []RowColorRule{
|
||||
{ID: "confirmed", Color: "#123abc", Enabled: true},
|
||||
{ID: "sent", Color: "red; background:url(x)", Enabled: true},
|
||||
{ID: "to_send", Color: "", Enabled: true},
|
||||
}}
|
||||
got := normRowColors(in)
|
||||
|
||||
byID := map[string]RowColorRule{}
|
||||
for _, r := range got.Rules {
|
||||
byID[r.ID] = r
|
||||
}
|
||||
if byID["confirmed"].Color != "#123abc" {
|
||||
t.Errorf("a valid colour was rewritten: %q", byID["confirmed"].Color)
|
||||
}
|
||||
if byID["sent"].Color != rowColorDefaults["sent"] {
|
||||
t.Errorf("an injection attempt survived: %q", byID["sent"].Color)
|
||||
}
|
||||
if byID["to_send"].Color != rowColorDefaults["to_send"] {
|
||||
t.Errorf("an empty colour was kept: %q", byID["to_send"].Color)
|
||||
}
|
||||
}
|
||||
|
||||
// Priority lives in the data, not in a chain of ifs: a contact confirmed on
|
||||
// LoTW AND by card is confirmed, not "sent, awaiting reply". The panel shows
|
||||
// the rules in the order they apply, so that order must survive a round trip.
|
||||
func TestRowColorsKeepPriorityOrder(t *testing.T) {
|
||||
// Saved in a jumbled order, as a hand-edited settings row could be.
|
||||
got := normRowColors(RowColorSettings{Rules: []RowColorRule{
|
||||
{ID: "to_send", Color: "#111111"},
|
||||
{ID: "confirmed", Color: "#222222"},
|
||||
}})
|
||||
if len(got.Rules) != len(rowColorOrder) {
|
||||
t.Fatalf("got %d rules, want every one present", len(got.Rules))
|
||||
}
|
||||
for i, id := range rowColorOrder {
|
||||
if got.Rules[i].ID != id {
|
||||
t.Errorf("rule %d is %q, want %q", i, got.Rules[i].ID, id)
|
||||
}
|
||||
}
|
||||
// The saved colours survived the reordering: each stayed with its own rule.
|
||||
byID := map[string]string{}
|
||||
for _, r := range got.Rules {
|
||||
byID[r.ID] = r.Color
|
||||
}
|
||||
if byID["to_send"] != "#111111" || byID["confirmed"] != "#222222" {
|
||||
t.Errorf("colours moved between rules: %v", byID)
|
||||
}
|
||||
}
|
||||
|
||||
// Style and strength are normalised, not rejected: they only shape a colour, and
|
||||
// a value that got away is a slider, not a fault worth resetting the operator's
|
||||
// whole choice for.
|
||||
func TestRowColorsNormaliseStyleAndIntensity(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
inStyle string
|
||||
inPct int
|
||||
wantStyle string
|
||||
wantPct int
|
||||
}{
|
||||
{"bar", 12, "bar", 12},
|
||||
{"tint", 30, "tint", 30},
|
||||
{"both", 45, "both", 45},
|
||||
{"", 0, "bar", 12}, // never configured
|
||||
{"wallpaper", 12, "bar", 12}, // not a style we draw
|
||||
{"tint", 900, "tint", 45}, // clamped, not reset
|
||||
{"tint", -5, "tint", 12},
|
||||
} {
|
||||
got := normRowColors(RowColorSettings{Style: tc.inStyle, Intensity: tc.inPct})
|
||||
if got.Style != tc.wantStyle || got.Intensity != tc.wantPct {
|
||||
t.Errorf("(%q,%d) -> (%q,%d), want (%q,%d)",
|
||||
tc.inStyle, tc.inPct, got.Style, got.Intensity, tc.wantStyle, tc.wantPct)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Channels are normalised to the canonical order, and anything unknown is
|
||||
// dropped. Empty stays empty, because empty means "every channel" — a rule the
|
||||
// operator has not narrowed must not silently become a rule about nothing.
|
||||
func TestRowColorChannelsNormalise(t *testing.T) {
|
||||
got := normRowColors(RowColorSettings{Rules: []RowColorRule{
|
||||
{ID: "confirmed", Color: "#111111", Channels: []string{"LOTW", "pigeon", "qsl", "lotw"}},
|
||||
{ID: "sent", Color: "#222222"},
|
||||
}})
|
||||
byID := map[string][]string{}
|
||||
for _, r := range got.Rules {
|
||||
byID[r.ID] = r.Channels
|
||||
}
|
||||
if len(byID["confirmed"]) != 2 || byID["confirmed"][0] != "qsl" || byID["confirmed"][1] != "lotw" {
|
||||
t.Errorf("confirmed channels = %v, want [qsl lotw] — canonical order, deduped, unknown dropped", byID["confirmed"])
|
||||
}
|
||||
if len(byID["sent"]) != 0 {
|
||||
t.Errorf("sent channels = %v, want empty (meaning every channel)", byID["sent"])
|
||||
}
|
||||
}
|
||||
|
||||
// The order IS the priority, and "to send" leads. A contact confirmed on LoTW
|
||||
// that still owes a paper card must read as owing the card — put after
|
||||
// "confirmed" it would go green and never be printed.
|
||||
func TestToSendOutranksConfirmed(t *testing.T) {
|
||||
got := normRowColors(RowColorSettings{})
|
||||
if got.Rules[0].ID != "to_send" {
|
||||
t.Errorf("first rule is %q, want to_send", got.Rules[0].ID)
|
||||
}
|
||||
if got.Rules[len(got.Rules)-1].ID != "worked" {
|
||||
t.Errorf("last rule is %q, want worked as the catch-all", got.Rules[len(got.Rules)-1].ID)
|
||||
}
|
||||
}
|
||||
+41
-6
@@ -21,6 +21,8 @@ import (
|
||||
"hamlog/internal/applog"
|
||||
"hamlog/internal/bandopen"
|
||||
"hamlog/internal/cluster"
|
||||
"hamlog/internal/geo"
|
||||
"hamlog/internal/gridcache"
|
||||
"hamlog/internal/pskr"
|
||||
)
|
||||
|
||||
@@ -127,24 +129,53 @@ func (a *App) startBandOpenFeed() {
|
||||
}
|
||||
s := a.GetBandOpenSettings()
|
||||
a.bandOpen.on.Store(s.Enabled)
|
||||
a.setBandOpenBands(s.Bands)
|
||||
if !s.Enabled {
|
||||
// Put out whatever is currently lit. Leaving the badges up would keep
|
||||
// announcing an opening from a watch that is now off, and they only fade
|
||||
// on a timer fed by spots this path no longer looks at — so they would
|
||||
// hang there until the app restarted.
|
||||
a.clearBandOpenings()
|
||||
}
|
||||
chaseGrids := a.gridStore != nil
|
||||
if !s.Enabled && !chaseGrids {
|
||||
return
|
||||
}
|
||||
// Every spot is measured from the operator's position. Without one there is
|
||||
// nothing to measure, and a detector fed unmeasurable spots reports nothing
|
||||
// while looking like it is working.
|
||||
if !a.opSet {
|
||||
applog.Printf("bandopen: no station grid set — the opening watch needs one to measure a path")
|
||||
applog.Printf("pskr: no station grid set — the feed needs one to measure a path")
|
||||
return
|
||||
}
|
||||
|
||||
// Grid chasing wants every band; the opening watch wants its four. "+" is the
|
||||
// MQTT single-level wildcard, so one subscription per square covers the lot.
|
||||
bands := s.Bands
|
||||
if chaseGrids {
|
||||
bands = []string{"+"}
|
||||
}
|
||||
// Filter at the BROKER on the receiver's square rather than receiving the
|
||||
// world and discarding it here. Measured on the live feed: the four opening
|
||||
// bands unfiltered are 83 messages a second, of which roughly one in a
|
||||
// hundred survived the NearKm test below. One ring of squares — about the
|
||||
// same 300 km — is 0.2 to 1.2 a second, and the same for every operator,
|
||||
// where filtering by DXCC ranged from 1.2 (OH) to 72.5 (K).
|
||||
rxGrids := geo.NeighbourGrids(a.opLat, a.opLon, 1)
|
||||
|
||||
var onGrid func(call, grid string)
|
||||
if chaseGrids {
|
||||
onGrid = func(call, grid string) { a.rememberDecodeGrid(call, grid, gridcache.SourceMQTT) }
|
||||
}
|
||||
var onSpot func(pskr.Spot)
|
||||
if s.Enabled {
|
||||
onSpot = a.feedBandOpen
|
||||
}
|
||||
|
||||
a.pskr = pskr.New(pskr.Config{
|
||||
Bands: s.Bands,
|
||||
OpLat: a.opLat, OpLon: a.opLon,
|
||||
Bands: bands,
|
||||
RxGrids: rxGrids,
|
||||
OpLat: a.opLat, OpLon: a.opLon,
|
||||
Geo: func(grid string) (int, int, bool) {
|
||||
lat, lon, ok := gridToLatLon(grid)
|
||||
if !ok {
|
||||
@@ -156,12 +187,16 @@ func (a *App) startBandOpenFeed() {
|
||||
b := int(initialBearingDeg(a.opLat, a.opLon, lat, lon) + 0.5)
|
||||
return d, b, true
|
||||
},
|
||||
OnSpot: a.feedBandOpen,
|
||||
OnSpot: onSpot,
|
||||
OnGrid: onGrid,
|
||||
Logf: applog.Printf,
|
||||
})
|
||||
if err := a.pskr.Start(); err != nil {
|
||||
applog.Printf("bandopen: PSK Reporter feed did not start: %v", err)
|
||||
applog.Printf("pskr: feed did not start: %v", err)
|
||||
return
|
||||
}
|
||||
applog.Printf("pskr: feed up — bands %v, %d receiver squares (openings=%v, grids=%v)",
|
||||
bands, len(rxGrids), s.Enabled, chaseGrids)
|
||||
}
|
||||
|
||||
// feedBandOpen hands one PSK Reporter decode to the detector.
|
||||
@@ -170,7 +205,7 @@ func (a *App) startBandOpenFeed() {
|
||||
// so it does the least possible: the detector's own window and de-duplication
|
||||
// by callsign are what turn that flood into one announcement.
|
||||
func (a *App) feedBandOpen(s pskr.Spot) {
|
||||
if !bandopen.Watched(s.Band) {
|
||||
if !a.bandOpenWanted(s.Band) {
|
||||
return
|
||||
}
|
||||
a.bandOpen.mu.Lock()
|
||||
|
||||
+45
-3
@@ -28,8 +28,15 @@ type bandOpenState struct {
|
||||
// settings query per spot is exactly what the rest of this file avoids.
|
||||
// startBandOpenFeed owns it — it runs at startup and again on every save, so
|
||||
// the switch takes effect without a restart.
|
||||
on atomic.Bool
|
||||
mu sync.Mutex
|
||||
on atomic.Bool
|
||||
// bands is the operator's SELECTED set, held as map[string]bool.
|
||||
//
|
||||
// bandopen.Watched only says which bands the detector is capable of; it
|
||||
// knows nothing about the chips in Settings. Nothing checked the selection
|
||||
// on either feed path, so unticking 10 m and 2 m changed the subscription and
|
||||
// left the cluster path announcing them anyway.
|
||||
bands atomic.Value
|
||||
mu sync.Mutex
|
||||
det *bandopen.Detector
|
||||
last []bandopen.Opening // most recent first, for the UI
|
||||
// live holds the announced openings that are still going, keyed by band, and
|
||||
@@ -60,7 +67,7 @@ func (a *App) detectBandOpening(s cluster.Spot) {
|
||||
// itself ran on every ordinary cluster spot. So an operator who had never
|
||||
// enabled the watch still got opening banners, from a feature they had
|
||||
// deliberately left off.
|
||||
if !a.bandOpen.on.Load() {
|
||||
if !a.bandOpen.on.Load() || !a.bandOpenWanted(s.Band) {
|
||||
return
|
||||
}
|
||||
// No operator grid = no distance and no bearing on the spot, and the whole
|
||||
@@ -140,6 +147,41 @@ func (a *App) GetLiveOpenings() []bandopen.Opening {
|
||||
return out
|
||||
}
|
||||
|
||||
// bandOpenWanted reports whether the operator has this band ticked. Falls back
|
||||
// to the detector's own set until a selection has been stored, so a band is
|
||||
// never silently dropped before the settings have been read.
|
||||
func (a *App) bandOpenWanted(band string) bool {
|
||||
if !bandopen.Watched(band) {
|
||||
return false
|
||||
}
|
||||
sel, ok := a.bandOpen.bands.Load().(map[string]bool)
|
||||
if !ok || len(sel) == 0 {
|
||||
return true
|
||||
}
|
||||
return sel[strings.ToLower(strings.TrimSpace(band))]
|
||||
}
|
||||
|
||||
// setBandOpenBands records the selection and puts out badges for bands that
|
||||
// have just been unticked — they fade on a timer fed by spots the detector no
|
||||
// longer looks at, so they would otherwise stay lit until the next restart.
|
||||
func (a *App) setBandOpenBands(bands []string) {
|
||||
sel := make(map[string]bool, len(bands))
|
||||
for _, b := range bands {
|
||||
sel[strings.ToLower(strings.TrimSpace(b))] = true
|
||||
}
|
||||
a.bandOpen.bands.Store(sel)
|
||||
|
||||
a.bandOpen.mu.Lock()
|
||||
defer a.bandOpen.mu.Unlock()
|
||||
for b := range a.bandOpen.live {
|
||||
if len(sel) > 0 && !sel[b] {
|
||||
delete(a.bandOpen.live, b)
|
||||
delete(a.bandOpen.aliveUntil, b)
|
||||
applog.Printf("bandopen: %s is no longer watched — badge cleared", strings.ToUpper(b))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// clearBandOpenings puts out every lit badge and forgets the detector's window.
|
||||
// Called when the watch is switched off: the badges fade on a timer fed by
|
||||
// spots the detector no longer looks at, so without this they would stay up
|
||||
|
||||
@@ -63,3 +63,56 @@ func TestClearBandOpeningsPutsTheBadgesOut(t *testing.T) {
|
||||
t.Error("the remembered openings were thrown away")
|
||||
}
|
||||
}
|
||||
|
||||
// Unticking a band in Settings must actually stop its announcements.
|
||||
//
|
||||
// Nothing checked the operator's selection on either feed path: bandopen.Watched
|
||||
// only says which bands the detector is CAPABLE of. So a station with just 6 m
|
||||
// ticked still got 10 m and 2 m badges, from the cluster path and — once grid
|
||||
// chasing widened the subscription to every band — from PSK Reporter too.
|
||||
func TestBandOpenHonoursTheSelectedBands(t *testing.T) {
|
||||
a := &App{opSet: true, opLat: 48, opLon: 2}
|
||||
a.bandOpen.on.Store(true)
|
||||
a.setBandOpenBands([]string{"6m"})
|
||||
|
||||
if !a.bandOpenWanted("6m") {
|
||||
t.Error("6m is ticked and was refused")
|
||||
}
|
||||
for _, b := range []string{"10m", "2m", "4m"} {
|
||||
if a.bandOpenWanted(b) {
|
||||
t.Errorf("%s is not ticked and was accepted", b)
|
||||
}
|
||||
}
|
||||
// A band the detector cannot watch at all stays out whatever is stored.
|
||||
if a.bandOpenWanted("20m") {
|
||||
t.Error("20m is not a watched band and was accepted")
|
||||
}
|
||||
|
||||
// And the cluster path must obey it too.
|
||||
a.detectBandOpening(cluster.Spot{
|
||||
DXCall: "K1ABC", Band: "10m", DistanceKm: 6000, ShortPath: 280, ReceivedAt: time.Now(),
|
||||
})
|
||||
if a.bandOpen.det != nil {
|
||||
t.Error("a spot on an unticked band reached the detector")
|
||||
}
|
||||
}
|
||||
|
||||
// Unticking a band puts its badge out. They fade on a timer fed by spots the
|
||||
// detector no longer looks at, so it would otherwise stay lit until a restart.
|
||||
func TestUntickingABandClearsItsBadge(t *testing.T) {
|
||||
a := &App{}
|
||||
a.bandOpen.live = map[string]bandopen.Opening{
|
||||
"6m": {Band: "6m", Calls: 9},
|
||||
"10m": {Band: "10m", Calls: 5},
|
||||
}
|
||||
a.bandOpen.aliveUntil = map[string]time.Time{
|
||||
"6m": time.Now().Add(time.Hour),
|
||||
"10m": time.Now().Add(time.Hour),
|
||||
}
|
||||
a.setBandOpenBands([]string{"6m"})
|
||||
|
||||
live := a.GetLiveOpenings()
|
||||
if len(live) != 1 || live[0].Band != "6m" {
|
||||
t.Errorf("live openings = %v, want only 6m", live)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,76 @@
|
||||
[
|
||||
{
|
||||
"version": "0.25.0",
|
||||
"date": "",
|
||||
"en": [
|
||||
"Cluster: \"Hide worked\" no longer hides a spot that is a new prefix, county, grid or park in an entity already worked.",
|
||||
"DX Cluster: a spot lifetime can be set — 5, 10, 15 minutes or your own value — after which spots leave the list and the band maps.",
|
||||
"Call lookup: QRZ.com now fills Name with the first name only, and optionally with the operator nickname instead.",
|
||||
"Entry form: a narrower left column, State beside the locator, and a new line with County, CQ, ITU and DXCC.",
|
||||
"Station Control: the short and long path headings are repeated under the compass, at a readable size and clickable.",
|
||||
"Compact mode: the window now fits the entry strip instead of leaving a band of empty space below it.",
|
||||
"HRDLog: an ON AIR option publishes your live frequency, mode and rig on hrdlog.net.",
|
||||
"ADIF: the county is exported as STATE,COUNTY as the standard requires, and a county imported that way now fills the state too.",
|
||||
"A /MM or /AM callsign no longer resolves to a country — RI1FJL/MM read as Franz Josef Land while the expedition was still sailing there."
|
||||
],
|
||||
"fr": [
|
||||
"Cluster : « Masquer les contactés » ne masque plus un spot qui est un nouveau préfixe, comté, carré ou parc dans une contrée déjà faite.",
|
||||
"Cluster DX : on peut fixer une durée de vie des spots — 5, 10, 15 minutes ou une valeur libre — au-delà de laquelle ils quittent la liste et les band maps.",
|
||||
"Recherche d indicatif : QRZ.com remplit désormais Nom avec le seul prénom, et au choix avec le surnom de l opérateur.",
|
||||
"Saisie : colonne de gauche plus étroite, État à côté du locator, et une nouvelle ligne Comté, CQ, ITU et DXCC.",
|
||||
"Contrôle station : les azimuts court et long chemin sont repris sous la boussole, lisibles et cliquables.",
|
||||
"Mode compact : la fenêtre épouse la bande de saisie au lieu de laisser une bande vide en dessous.",
|
||||
"HRDLog : une option ON AIR publie ta fréquence, ton mode et ta radio en direct sur hrdlog.net.",
|
||||
"ADIF : le comté est exporté sous la forme ÉTAT,COMTÉ comme l exige le standard, et un comté importé ainsi remplit aussi l état.",
|
||||
"Un indicatif en /MM ou /AM ne se résout plus en pays — RI1FJL/MM affichait Franz Josef Land alors que l expédition faisait encore route."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.24.9",
|
||||
"date": "",
|
||||
"en": [
|
||||
"Appearance: row colouring now defaults to a left stripe, with a filled row and its strength offered as choices.",
|
||||
"The band map now follows the cluster filters — LoTW only, spotter continent, hide worked, status and mode chips.",
|
||||
"Band map: stations that upload to LoTW now carry the same L badge as the cluster list, switchable in Appearance.",
|
||||
"LoTW: contacts TQSL leaves out — already uploaded, or outside the certificate date range — are no longer reported as uploaded.",
|
||||
"FT8: a contact is logged with the grid the station actually sent, not the home square from QRZ — expeditions were logged in the wrong place.",
|
||||
"SPE amplifier: switching it off is shown at once, instead of needing a second press of OFF.",
|
||||
"Appearance: row colouring is now Worked / Confirmed / QSL sent / To be sent, each scoped to the channels you pick — paper, LoTW, eQSL, QRZ.com."
|
||||
],
|
||||
"fr": [
|
||||
"Apparence : la coloration des lignes se fait par défaut sur une barre à gauche, la ligne remplie et son intensité restant proposées.",
|
||||
"La band map suit désormais les filtres du cluster — LoTW seulement, continent du spotter, masquer les contactés, statuts et modes.",
|
||||
"Band map : les stations qui utilisent LoTW portent le même badge L que la liste du cluster, activable dans Apparence.",
|
||||
"LoTW : les contacts que TQSL écarte — déjà envoyés, ou hors de la plage de dates du certificat — ne sont plus annoncés comme envoyés.",
|
||||
"FT8 : un contact est enregistré avec le locator réellement émis par la station, et non le carré du domicile depuis QRZ — les expéditions étaient logguées au mauvais endroit.",
|
||||
"Amplificateur SPE : l extinction s affiche immédiatement, au lieu de demander un second appui sur OFF.",
|
||||
"Apparence : la coloration des lignes devient Contacté / Confirmé / QSL envoyée / À envoyer, chacune limitée aux canaux choisis — papier, LoTW, eQSL, QRZ.com."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.24.8",
|
||||
"date": "",
|
||||
"en": [
|
||||
"Cluster: the grid cache now holds 100,000 callsigns and rotates instead of emptying itself, so locators stop vanishing from the list.",
|
||||
"New option \"Chase new grids\": locators learnt from your decodes and from PSK Reporter are kept in their own database, with their source.",
|
||||
"Band openings: the PSK Reporter feed is now filtered at the broker, which cuts it from about 83 messages a second to under two.",
|
||||
"Band openings: unticking a band now actually stops its announcements, and puts its badge out.",
|
||||
"Callsign lookup: the website, postal code and HamQTH profile picture are now read — the QSO web column was never filled by any lookup.",
|
||||
"New selectable column \"QSO number\": position in the log, 1 for the oldest contact.",
|
||||
"New Appearance settings: colour whole log rows by QSL status, with your own colours from a palette.",
|
||||
"The QSO number was missing on contacts logged from WSJT-X — that path bypassed the numbering."
|
||||
],
|
||||
"fr": [
|
||||
"Cluster : le cache de locators garde 100 000 indicatifs et tourne au lieu de se vider, les locators ne disparaissent donc plus de la liste.",
|
||||
"Nouvelle option « Chasse aux nouveaux carrés » : les locators appris de tes décodes et de PSK Reporter sont conservés dans leur propre base, avec leur source.",
|
||||
"Ouvertures de bande : le flux PSK Reporter est désormais filtré chez le broker, ce qui le fait passer d environ 83 messages par seconde à moins de deux.",
|
||||
"Ouvertures de bande : décocher une bande arrête réellement ses annonces et éteint son badge.",
|
||||
"Recherche d indicatif : le site web, le code postal et la photo de profil HamQTH sont désormais lus — la colonne web du QSO n était jamais remplie.",
|
||||
"Nouvelle colonne sélectionnable « Numéro de QSO » : position dans le log, 1 pour le contact le plus ancien.",
|
||||
"Nouveaux réglages Apparence : colorer les lignes entières du log selon le statut QSL, avec tes couleurs choisies dans une palette.",
|
||||
"Le numéro de QSO manquait sur les contacts enregistrés depuis WSJT-X — ce chemin contournait la numérotation."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.24.7",
|
||||
"date": "",
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"hamlog/internal/gridcache"
|
||||
)
|
||||
|
||||
// The grid cache used to drop EVERYTHING at its ceiling. That was survivable
|
||||
// while the only feed was this station's own decodes, which never reached it.
|
||||
// Fed by anything larger it is a cliff: every locator in the cluster list
|
||||
// disappears at once, periodically, and the operator sees the column empty
|
||||
// itself for no reason.
|
||||
//
|
||||
// Rotation keeps the previous generation, so a full cache costs the older half
|
||||
// and nothing more.
|
||||
func TestDecodeGridRotationKeepsThePreviousGeneration(t *testing.T) {
|
||||
a := &App{}
|
||||
|
||||
// Fill exactly one generation.
|
||||
for i := 0; i < decodeGridsCap; i++ {
|
||||
a.rememberDecodeGrid(fmt.Sprintf("CALL%06d", i), "JN36", gridcache.SourceDecode)
|
||||
}
|
||||
if got := a.lookupDecodeGrid("CALL000000"); got != "JN36" {
|
||||
t.Fatalf("first entry lost before any rotation: %q", got)
|
||||
}
|
||||
if a.decodeGridsOld != nil {
|
||||
t.Fatal("rotated early — the cap is the ceiling of ONE generation")
|
||||
}
|
||||
|
||||
// One more entry rotates.
|
||||
a.rememberDecodeGrid("NEWCALL", "IO91", gridcache.SourceDecode)
|
||||
if a.decodeGridsOld == nil {
|
||||
t.Fatal("did not rotate at the cap")
|
||||
}
|
||||
if got := a.lookupDecodeGrid("NEWCALL"); got != "IO91" {
|
||||
t.Errorf("the entry that caused the rotation was lost: %q", got)
|
||||
}
|
||||
// The whole previous generation is still readable — this is the point.
|
||||
if got := a.lookupDecodeGrid("CALL000000"); got != "JN36" {
|
||||
t.Errorf("a locator from the previous generation was dropped: %q — that is the cliff again", got)
|
||||
}
|
||||
if got := a.lookupDecodeGrid("CALL099999"); got != "JN36" {
|
||||
t.Errorf("previous generation incomplete: %q", got)
|
||||
}
|
||||
|
||||
// A second rotation is what finally retires the oldest half.
|
||||
for i := 0; i < decodeGridsCap; i++ {
|
||||
a.rememberDecodeGrid(fmt.Sprintf("SECOND%06d", i), "KP20", gridcache.SourceDecode)
|
||||
}
|
||||
if got := a.lookupDecodeGrid("CALL000000"); got != "" {
|
||||
t.Errorf("the cache is unbounded: %q survived two rotations", got)
|
||||
}
|
||||
if got := a.lookupDecodeGrid("NEWCALL"); got != "IO91" {
|
||||
t.Errorf("an entry one generation old was retired too early: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Callsigns are normalised on the way in AND on the way out, or a spot for
|
||||
// "f4bpo" would miss a grid learnt as "F4BPO".
|
||||
func TestDecodeGridCaseAndBlanks(t *testing.T) {
|
||||
a := &App{}
|
||||
a.rememberDecodeGrid(" f4bpo ", "JN36", gridcache.SourceDecode)
|
||||
if got := a.lookupDecodeGrid("F4BPO"); got != "JN36" {
|
||||
t.Errorf("lookup of the upper-case form failed: %q", got)
|
||||
}
|
||||
a.rememberDecodeGrid("", "JN36", gridcache.SourceDecode)
|
||||
a.rememberDecodeGrid("K1ABC", "", gridcache.SourceDecode)
|
||||
if got := a.lookupDecodeGrid("K1ABC"); got != "" {
|
||||
t.Errorf("stored an empty grid: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The write path runs on the decode goroutine while the cluster status builder
|
||||
// reads. Rotation swaps the map headers, so an unguarded read is a data race
|
||||
// rather than a stale value.
|
||||
//
|
||||
// This build is CGO-free, so -race is not available here; the test exercises the
|
||||
// interleaving and would fault on a concurrent map access even without it.
|
||||
func TestDecodeGridConcurrentAccess(t *testing.T) {
|
||||
a := &App{}
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 20000; i++ {
|
||||
a.rememberDecodeGrid(fmt.Sprintf("W%05d", i), "FN31", gridcache.SourceDecode)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 20000; i++ {
|
||||
_ = a.lookupDecodeGrid(fmt.Sprintf("W%05d", i))
|
||||
}
|
||||
}()
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// Only a CHANGE may reach the write batch.
|
||||
//
|
||||
// The feeds repeat themselves — the same station is reported by dozens of
|
||||
// receivers a minute — so queueing every report would put the whole stream in
|
||||
// the batch instead of the news in it, and turn a cache into a write amplifier.
|
||||
func TestOnlyChangesAreQueued(t *testing.T) {
|
||||
st, err := gridcache.Open(filepath.Join(t.TempDir(), "grids.db"), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
a := &App{gridStore: st}
|
||||
|
||||
a.rememberDecodeGrid("F4BPO", "JN36", gridcache.SourceDecode)
|
||||
if n := st.Pending(); n != 1 {
|
||||
t.Fatalf("a new locator queued %d writes, want 1", n)
|
||||
}
|
||||
if err := st.Flush(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The same report, a hundred times over, is not news.
|
||||
for i := 0; i < 100; i++ {
|
||||
a.rememberDecodeGrid("F4BPO", "JN36", gridcache.SourceDecode)
|
||||
}
|
||||
if n := st.Pending(); n != 0 {
|
||||
t.Errorf("unchanged reports queued %d writes — the batch would carry the whole feed", n)
|
||||
}
|
||||
|
||||
// A station that moved is.
|
||||
a.rememberDecodeGrid("F4BPO", "KP30", gridcache.SourceDecode)
|
||||
if n := st.Pending(); n != 1 {
|
||||
t.Errorf("a changed locator queued %d writes, want 1", n)
|
||||
}
|
||||
if got := a.lookupDecodeGrid("F4BPO"); got != "KP30" {
|
||||
t.Errorf("the map kept the old locator: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Rotation must be OFF while a store is attached: it would discard callsigns the
|
||||
// database still holds, and the lookup would then miss something we know.
|
||||
func TestNoRotationWhilePersisting(t *testing.T) {
|
||||
st, err := gridcache.Open(filepath.Join(t.TempDir(), "grids.db"), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
a := &App{gridStore: st}
|
||||
|
||||
for i := 0; i < decodeGridsCap+10; i++ {
|
||||
a.rememberDecodeGrid(fmt.Sprintf("CALL%06d", i), "JN36", gridcache.SourceDecode)
|
||||
}
|
||||
if a.decodeGridsOld != nil {
|
||||
t.Error("rotated while a store was attached — locators the database holds would go missing")
|
||||
}
|
||||
if got := a.lookupDecodeGrid("CALL000000"); got != "JN36" {
|
||||
t.Errorf("the first entry was dropped: %q", got)
|
||||
}
|
||||
}
|
||||
+167
-16
@@ -16,7 +16,7 @@ import {
|
||||
GetStartupStatus, CheckForUpdate, DownloadAndApplyUpdate, GetLiveStations, GetWhatsNew, GetChangelog,
|
||||
SMTPConfigured, SendLogToDeveloper,
|
||||
WorkedBefore,
|
||||
SetCompactMode,
|
||||
SetCompactMode, SetCompactHeight,
|
||||
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna, FlexApplyBandPower,
|
||||
GetSecretStatus, UnlockSecrets,
|
||||
RefreshCtyDat, DownloadAllReferenceLists,
|
||||
@@ -90,7 +90,8 @@ import { ExportFieldsDialog } from '@/components/ExportFieldsDialog';
|
||||
import { ShutdownProgress } from '@/components/ShutdownProgress';
|
||||
import { ClusterGrid } from '@/components/ClusterGrid';
|
||||
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
|
||||
import { applySpotDisplay, readSpotDisplayOptions, SPOT_DISPLAY_OPTIONS_EXPOSED } from '@/lib/spotDisplay';
|
||||
import { applySpotDisplay, readSpotDisplayOptions, spotIsWorked, SPOT_DISPLAY_OPTIONS_EXPOSED } from '@/lib/spotDisplay';
|
||||
import { GetRowColors, GetSpotTTLMinutes, IsNewUSCounty } from '../wailsjs/go/main/App';
|
||||
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
|
||||
import { NetControlPanel } from '@/components/NetControlPanel';
|
||||
import { ContestPanel, CONTEST_DEFAULT, type ContestSession } from '@/components/ContestPanel';
|
||||
@@ -528,6 +529,28 @@ export default function App() {
|
||||
setCompact(next);
|
||||
SetCompactMode(next);
|
||||
}
|
||||
// Fit the compact window to what was actually rendered.
|
||||
//
|
||||
// The height was a constant "tuned so the compact entry strip fits in a single
|
||||
// row". A constant tuned against a layout stops being true the moment the
|
||||
// layout changes, and this one outlived a strip that had since lost a row —
|
||||
// leaving a band of empty window under the fields. Measure instead.
|
||||
const compactRootRef = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (!compact) return;
|
||||
const fit = () => {
|
||||
const el = compactRootRef.current;
|
||||
if (!el) return;
|
||||
// Rounded, so a sub-pixel reflow cannot start a resize loop.
|
||||
// + the compact topbar, which is a fixed h-8 (32px), + a couple of pixels
|
||||
// for the border so the strip is never clipped by one row of anti-aliasing.
|
||||
SetCompactHeight(Math.round(el.getBoundingClientRect().height) + 32 + 2);
|
||||
};
|
||||
const id = window.setTimeout(fit, 60); // let the strip paint first
|
||||
const ro = new ResizeObserver(fit);
|
||||
if (compactRootRef.current) ro.observe(compactRootRef.current);
|
||||
return () => { window.clearTimeout(id); ro.disconnect(); };
|
||||
}, [compact]);
|
||||
|
||||
// CAT — receives live rig state via Wails events.
|
||||
const [catState, setCatState] = useState<CATState>({ enabled: false, connected: false } as any);
|
||||
@@ -1923,6 +1946,35 @@ export default function App() {
|
||||
// change). The grid reads them straight from the row — no per-page backend
|
||||
// recompute — so here we just parse the stored JSON string into the code→ref
|
||||
// object the award columns expect (keys are already upper-case).
|
||||
// Row colouring by QSL status (Settings → Appearance). Reloaded when the
|
||||
// settings dialog closes, which is the only place it changes.
|
||||
const [rowColors, setRowColors] = useState<any>(null);
|
||||
useEffect(() => { GetRowColors().then(setRowColors).catch(() => {}); }, [showSettings]);
|
||||
// Spot lifetime (Settings → DX Cluster). Spots are actually REMOVED rather
|
||||
// than filtered at render: the cluster list, every band map and the counts all
|
||||
// read the same array, so pruning it once is what makes the setting mean the
|
||||
// same thing everywhere — and it gives the memory back.
|
||||
const [spotTTLMin, setSpotTTLMin] = useState(0);
|
||||
useEffect(() => { GetSpotTTLMinutes().then(setSpotTTLMin).catch(() => {}); }, [showSettings]);
|
||||
useEffect(() => {
|
||||
if (spotTTLMin <= 0) return; // 0 = keep until the count cap pushes them out
|
||||
const sweep = () => {
|
||||
const cutoff = Date.now() - spotTTLMin * 60_000;
|
||||
setSpots((arr) => {
|
||||
const kept = arr.filter((sp) => {
|
||||
const t = Date.parse((sp as any).received_at ?? '');
|
||||
return isNaN(t) || t >= cutoff; // an unparseable stamp is never a reason to drop a spot
|
||||
});
|
||||
return kept.length === arr.length ? arr : kept;
|
||||
});
|
||||
};
|
||||
sweep();
|
||||
// Half a minute: fine enough that a 5-minute setting is honoured closely,
|
||||
// coarse enough to be invisible next to the spot stream itself.
|
||||
const id = window.setInterval(sweep, 30_000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [spotTTLMin]);
|
||||
|
||||
const qsosWithAwards = useMemo(
|
||||
() => (qsos as any[]).map((q) => ({ ...q, award_refs: parseAwardRefs(q.award_refs) })),
|
||||
[qsos],
|
||||
@@ -3303,7 +3355,7 @@ export default function App() {
|
||||
sat_name: details.sat_name, sat_mode: details.sat_mode,
|
||||
contest_id: details.contest_id,
|
||||
srx: srxE.num, stx: stxE.num, srx_string: srxE.str, stx_string: stxE.str,
|
||||
email: details.email,
|
||||
email: details.email, web: details.web,
|
||||
};
|
||||
applyAwardRefs(payload, details.award_refs ?? '', awardFieldRef.current);
|
||||
// Contest mode overrides the exchange fields: CONTEST_ID, the sent
|
||||
@@ -3666,6 +3718,7 @@ export default function App() {
|
||||
ituz: d.ituz ?? (last.ituz || undefined),
|
||||
cont: d.cont || last.cont || '',
|
||||
email: d.email || last.email || '',
|
||||
web: d.web || last.web || '',
|
||||
qsl_via: d.qsl_via || last.qsl_via || '',
|
||||
};
|
||||
});
|
||||
@@ -3728,6 +3781,7 @@ export default function App() {
|
||||
ituz: d.ituz ?? (r.ituz || undefined),
|
||||
cont: d.cont || (r.cont ?? ''),
|
||||
email: d.email || (r.email ?? ''),
|
||||
web: d.web || (r.web ?? ''),
|
||||
qsl_via: d.qsl_via || (r.qsl_via ?? ''),
|
||||
}));
|
||||
// Backfill anything the provider didn't supply from the last time we worked
|
||||
@@ -4407,6 +4461,67 @@ export default function App() {
|
||||
onBlur={() => setQth(titleCase)} />
|
||||
</div>
|
||||
);
|
||||
// State sits between QTH and the locator: it is part of the same "where is
|
||||
// this station" reading, and it is what several awards key off (WAS, the US
|
||||
// county work). Narrow — it holds two or three characters.
|
||||
const stateBlock = (
|
||||
<div className="flex flex-col w-[70px] shrink-0"><Label className="mb-1 h-3.5">{t('field.state')}</Label>
|
||||
<Input value={details.state ?? ''} className="font-mono"
|
||||
onChange={(e) => setDetails((d) => ({ ...d, state: e.target.value.toUpperCase() }))} />
|
||||
</div>
|
||||
);
|
||||
// County beside State: the lookup fills QTH, State, County and the locator
|
||||
// together, and they are read together. Wider than State because a county name
|
||||
// is a name — "St. Tammany Parish", not a code.
|
||||
// The row Comment/Note freed by moving onto one line: county, zones and the
|
||||
// ADIF entity number. All four are filled by the lookup and were previously
|
||||
// only visible after opening a tab — which is the wrong place for values an
|
||||
// operator wants to SEE are right before pressing Log.
|
||||
//
|
||||
// A numeric field stores undefined when cleared, not 0: zero is a real DXCC
|
||||
// number for "no entity" and a real answer for a zone, so an empty box must
|
||||
// stay empty rather than assert something.
|
||||
// NEW badge on the county — the same answer the Details tab gives, moved to
|
||||
// where the county is now typed. Debounced: this field is typed into as well
|
||||
// as filled by the lookup, and the check hits the database.
|
||||
const [entryNewCounty, setEntryNewCounty] = useState(false);
|
||||
useEffect(() => {
|
||||
const cnty = (details.cnty ?? '').trim();
|
||||
if (!cnty) { setEntryNewCounty(false); return; }
|
||||
let alive = true;
|
||||
const id = window.setTimeout(async () => {
|
||||
try {
|
||||
const isNew = await IsNewUSCounty(details.state ?? '', cnty);
|
||||
if (alive) setEntryNewCounty(!!isNew);
|
||||
} catch { if (alive) setEntryNewCounty(false); }
|
||||
}, 300);
|
||||
return () => { alive = false; window.clearTimeout(id); };
|
||||
}, [details.state, details.cnty]);
|
||||
|
||||
const num = (v: string): number | undefined => {
|
||||
const s = v.replace(/[^0-9]/g, '');
|
||||
return s === '' ? undefined : parseInt(s, 10);
|
||||
};
|
||||
const geoDetailRow = (
|
||||
<div className="flex gap-4 items-end">
|
||||
<div className="flex flex-col flex-1 min-w-0"><Label className="mb-1 h-3.5">{t('field.cnty')}</Label>
|
||||
<Input value={details.cnty ?? ''}
|
||||
onChange={(e) => setDetails((d) => ({ ...d, cnty: e.target.value }))} />
|
||||
</div>
|
||||
<div className="flex flex-col w-[64px] shrink-0"><Label className="mb-1 h-3.5">{t('field.cqz')}</Label>
|
||||
<Input value={details.cqz ?? ''} className="font-mono"
|
||||
onChange={(e) => setDetails((d) => ({ ...d, cqz: num(e.target.value) }))} />
|
||||
</div>
|
||||
<div className="flex flex-col w-[64px] shrink-0"><Label className="mb-1 h-3.5">{t('field.ituz')}</Label>
|
||||
<Input value={details.ituz ?? ''} className="font-mono"
|
||||
onChange={(e) => setDetails((d) => ({ ...d, ituz: num(e.target.value) }))} />
|
||||
</div>
|
||||
<div className="flex flex-col w-[72px] shrink-0"><Label className="mb-1 h-3.5">{t('field.dxcc')}</Label>
|
||||
<Input value={details.dxcc ?? ''} className="font-mono"
|
||||
onChange={(e) => setDetails((d) => ({ ...d, dxcc: num(e.target.value) }))} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const gridBlock = (
|
||||
<div className="flex flex-col w-[76px] shrink-0"><Label className="mb-1 h-3.5">{t('field.grid')}</Label>
|
||||
<Input value={grid} placeholder="JN05" className="font-mono" onChange={(e) => { setGrid(e.target.value); markEdited('grid'); }} />
|
||||
@@ -4710,12 +4825,21 @@ export default function App() {
|
||||
// Cluster spots after every active filter (band / mode / status / search /
|
||||
// hide-worked / group). Shared by the Cluster tab and the Main-view cluster
|
||||
// pane so both show exactly the same list.
|
||||
const clusterRenderedRows = useMemo(() => {
|
||||
const bandsActive = clusterLockBand ? new Set([band]) : clusterBands;
|
||||
// Everything the operator has said about WHICH STATIONS they care about —
|
||||
// LoTW only, the spotter's continent, hide worked, the status and mode chips,
|
||||
// the search box, the source node.
|
||||
//
|
||||
// Shared with the band map, which used to receive the raw stream filtered by
|
||||
// band alone: switching on "LoTW users only" changed the cluster list and left
|
||||
// the panadapter showing everyone. Two views of one spot stream disagreeing
|
||||
// about the same spot is the fault lib/spotDisplay already exists to prevent.
|
||||
//
|
||||
// The BAND filter is deliberately not in here: a band map's band IS its
|
||||
// filter, and applying the cluster's would empty every map but one.
|
||||
const spotPassesStationFilters = useCallback((s: ClusterSpot): boolean => {
|
||||
const search = clusterSearch.trim().toUpperCase();
|
||||
const list = spots.filter((s) => {
|
||||
{
|
||||
if (clusterFilterSource && s.source_id !== clusterFilterSource) return false;
|
||||
if (bandsActive.size > 0 && !bandsActive.has(s.band ?? '')) return false;
|
||||
if (search && !s.dx_call.includes(search)) return false;
|
||||
if (clusterLockMode) {
|
||||
const spotMode = inferSpotMode(s.comment ?? '', s.freq_hz);
|
||||
@@ -4764,9 +4888,27 @@ export default function App() {
|
||||
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
||||
const e = spotStatus[k];
|
||||
if (!e) return false;
|
||||
if (e.worked_call || e.status === 'worked') return false;
|
||||
// A spot that is NEW for something is not "worked", whatever the entity
|
||||
// says. The extra markers are orthogonal to the entity status — the same
|
||||
// rule the band map already states — so a new prefix, county, grid or
|
||||
// park in an entity worked years ago is exactly what an operator is
|
||||
// hunting, and hiding it is the opposite of what was asked.
|
||||
//
|
||||
// Reported on BH2SWB and 4X9AA: both flagged NEW PFX, neither ever
|
||||
// worked, both vanished the moment "hide worked" went on.
|
||||
const isNew = !!(e.new_pota || e.new_county || e.new_pfx || e.new_grid);
|
||||
if (!isNew && (e.worked_call || e.status === 'worked')) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}, [clusterFilterSource, clusterSearch, clusterLockMode, mode, clusterModeFilter,
|
||||
clusterStatusFilter, spotStatus, clusterLotwOnly, clusterSpotterConts, clusterHideWorked]);
|
||||
|
||||
const clusterRenderedRows = useMemo(() => {
|
||||
const bandsActive = clusterLockBand ? new Set([band]) : clusterBands;
|
||||
const list = spots.filter((s) => {
|
||||
if (bandsActive.size > 0 && !bandsActive.has(s.band ?? '')) return false;
|
||||
return spotPassesStationFilters(s);
|
||||
});
|
||||
let rendered = list as (ClusterSpot & { repeats?: number })[];
|
||||
if (clusterGroup) {
|
||||
@@ -5068,6 +5210,7 @@ export default function App() {
|
||||
// rewrote the other's widths.
|
||||
storageKey="mainpane"
|
||||
rows={qsosWithAwards as any}
|
||||
rowColors={rowColors}
|
||||
myGrid={station.my_grid}
|
||||
total={total}
|
||||
awardCols={awardCols}
|
||||
@@ -5771,7 +5914,7 @@ export default function App() {
|
||||
Enter from any <input> inside the strip logs the QSO. Radix Selects
|
||||
render as <button> elements and are ignored by this handler — they
|
||||
keep their own keyboard behaviour. */}
|
||||
<div className={cn(!compact && 'flex gap-2.5 items-stretch px-2.5 pt-2.5 shrink-0')}>
|
||||
<div ref={compactRootRef} className={cn(!compact && 'flex gap-2.5 items-stretch px-2.5 pt-2.5 shrink-0')}>
|
||||
<section
|
||||
className={cn('bg-card shadow-sm border-border',
|
||||
compact
|
||||
@@ -5826,25 +5969,30 @@ export default function App() {
|
||||
to be pushed outside the entry panel and clipped. Wrapping drops
|
||||
it to its own line instead, which stays readable. */}
|
||||
<div className="flex gap-4 items-end flex-wrap">
|
||||
<div className="flex flex-col w-[300px] shrink-0"><Label className="mb-1 h-3.5">Name</Label>
|
||||
<div className="flex flex-col w-[230px] shrink-0"><Label className="mb-1 h-3.5">{t('field.name')}</Label>
|
||||
<Input value={name} onChange={(e) => { setName(e.target.value); markEdited('name'); }}
|
||||
onBlur={() => setName(titleCase)} />
|
||||
</div>
|
||||
{qthBlock}
|
||||
{stateBlock}
|
||||
{gridBlock}
|
||||
</div>
|
||||
|
||||
{/* Row 3: tight left detail column (Band/Mode/Country) and
|
||||
single-line Comment/Note on the right. */}
|
||||
Comment/Note SIDE BY SIDE on the right — one line each was a lot
|
||||
of vertical space for two fields that are usually short. */}
|
||||
<div className="flex gap-4 items-start">
|
||||
<div className="flex flex-col gap-1.5 w-[300px] shrink-0">
|
||||
<div className="flex flex-col gap-1.5 w-[230px] shrink-0">
|
||||
{bandRow}
|
||||
{modeRow}
|
||||
{countryRow}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 flex-1 min-w-0">
|
||||
{commentLine}
|
||||
{noteLine}
|
||||
{geoDetailRow}
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1 min-w-0">{commentLine}</div>
|
||||
<div className="flex-1 min-w-0">{noteLine}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6351,6 +6499,7 @@ export default function App() {
|
||||
<RecentQSOsGrid
|
||||
key={`rqg2-${activeProfileId ?? 'x'}`}
|
||||
rows={qsosWithAwards as any}
|
||||
rowColors={rowColors}
|
||||
myGrid={station.my_grid}
|
||||
total={total}
|
||||
awardCols={awardCols}
|
||||
@@ -6766,13 +6915,14 @@ export default function App() {
|
||||
</div>
|
||||
<BandMap
|
||||
band={b}
|
||||
spots={spots.filter((s) => s.band === b)}
|
||||
spots={spots.filter((s) => s.band === b && spotPassesStationFilters(s))}
|
||||
spotStatus={spotStatus}
|
||||
currentFreqHz={band === b && freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0}
|
||||
onSpotClick={handleSpotClick}
|
||||
onClose={() => toggleBandMapBand(b)}
|
||||
hideDigital={bandMapHideFt}
|
||||
fitToBand={bandMapFit}
|
||||
showLotw={!!rowColors?.bandmap_lotw}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -6802,11 +6952,12 @@ export default function App() {
|
||||
side={bandMapSide}
|
||||
onToggleSide={toggleBandMapSide}
|
||||
band={band}
|
||||
spots={spots.filter((s) => s.band === band)}
|
||||
spots={spots.filter((s) => s.band === band && spotPassesStationFilters(s))}
|
||||
spotStatus={spotStatus}
|
||||
currentFreqHz={band && freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0}
|
||||
onSpotClick={handleSpotClick}
|
||||
onClose={() => setBandMapShown(false)}
|
||||
showLotw={!!rowColors?.bandmap_lotw}
|
||||
keyNav
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { GetRowColors, SaveRowColors } from '../../wailsjs/go/main/App';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { RowColorSettings } from '@/lib/rowColors';
|
||||
|
||||
// A fixed palette plus a free picker. Muted values on purpose: they are
|
||||
// composited at low opacity over a dark grid, where a saturated colour reads as
|
||||
// an error state rather than a status.
|
||||
const PALETTE = [
|
||||
'#16a34a', '#0ea5e9', '#f59e0b', '#a855f7',
|
||||
'#dc2626', '#14b8a6', '#eab308', '#ec4899',
|
||||
'#64748b', '#84cc16', '#6366f1', '#f97316',
|
||||
];
|
||||
|
||||
// The rule ids the backend orders; the labels live here so a translation never
|
||||
// travels through the settings row.
|
||||
const LABELS: Record<string, string> = {
|
||||
to_send: 'appr.ruleToSend',
|
||||
confirmed: 'appr.ruleConfirmed',
|
||||
sent: 'appr.ruleSent',
|
||||
worked: 'appr.ruleWorked',
|
||||
};
|
||||
|
||||
// The channels a rule can be scoped to. "worked" is the catch-all — it means
|
||||
// nothing on ANY channel — so narrowing it would say nothing.
|
||||
const CHANNELS = ['qsl', 'lotw', 'eqsl', 'qrz'] as const;
|
||||
const CHANNEL_LABELS: Record<string, string> = {
|
||||
qsl: 'appr.chQsl', lotw: 'LoTW', eqsl: 'eQSL', qrz: 'QRZ.com',
|
||||
};
|
||||
|
||||
export function AppearancePanel() {
|
||||
const { t } = useI18n();
|
||||
const [cfg, setCfg] = useState<RowColorSettings | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try { setCfg((await GetRowColors()) as any); } catch { /* defaults on the backend */ }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const save = (next: RowColorSettings) => {
|
||||
setCfg(next);
|
||||
SaveRowColors(next as any).catch(() => {});
|
||||
};
|
||||
const patchRule = (id: string, patch: Partial<{ color: string; enabled: boolean; channels: string[] }>) => {
|
||||
if (!cfg) return;
|
||||
save({ ...cfg, rules: cfg.rules.map((r) => (r.id === id ? { ...r, ...patch } : r)) });
|
||||
};
|
||||
|
||||
// The rule card shows the row exactly as the grid will draw it, so the choice
|
||||
// is made by looking rather than by imagining.
|
||||
const preview = (color: string): Record<string, string> => {
|
||||
const st = cfg?.style ?? 'bar';
|
||||
const pct = cfg?.intensity ?? 12;
|
||||
const out: Record<string, string> = {};
|
||||
if (st === 'tint' || st === 'both') out.backgroundColor = `color-mix(in srgb, ${color} ${pct}%, transparent)`;
|
||||
if (st === 'bar' || st === 'both') out.boxShadow = `inset 3px 0 0 ${color}`;
|
||||
return out;
|
||||
};
|
||||
|
||||
if (!cfg) return <div className="p-1 text-sm text-muted-foreground">…</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={!!cfg.bandmap_lotw} className="mt-0.5"
|
||||
onCheckedChange={(c) => save({ ...cfg, bandmap_lotw: !!c } as any)} />
|
||||
<span>{t('appr.bandmapLotw')} <span className="text-xs text-muted-foreground">{t('appr.bandmapLotwHint')}</span></span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={cfg.enabled} className="mt-0.5"
|
||||
onCheckedChange={(c) => save({ ...cfg, enabled: !!c })} />
|
||||
<span>{t('appr.enable')} <span className="text-xs text-muted-foreground">{t('appr.enableHint')}</span></span>
|
||||
</label>
|
||||
|
||||
{cfg.enabled && (
|
||||
<div className="space-y-3">
|
||||
{/* Style first: it decides whether the colours below are a signal or a
|
||||
wallpaper, which matters more than which hue they are. */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<span className="text-sm">{t('appr.style')}</span>
|
||||
<div className="inline-flex rounded-md border border-border overflow-hidden text-xs">
|
||||
{(['bar', 'tint', 'both'] as const).map((v) => (
|
||||
<button key={v} type="button" onClick={() => save({ ...cfg, style: v })}
|
||||
className={cn('px-3 py-1.5 font-medium', (cfg.style ?? 'bar') === v ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
|
||||
{t('appr.style' + v[0].toUpperCase() + v.slice(1))}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{(cfg.style ?? 'bar') !== 'bar' && (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
{t('appr.intensity')}
|
||||
<input type="range" min={5} max={45} step={1} value={cfg.intensity ?? 12}
|
||||
onChange={(e) => save({ ...cfg, intensity: parseInt(e.target.value, 10) })}
|
||||
className="w-32 accent-[var(--primary)]" />
|
||||
<span className="font-mono text-xs text-muted-foreground w-8">{cfg.intensity ?? 12}%</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
{/* Order matters and is shown: a contact is usually several of these at
|
||||
once, and the first match wins. */}
|
||||
<p className="text-xs text-muted-foreground">{t('appr.orderHint')}</p>
|
||||
{cfg.rules.map((r, i) => (
|
||||
<div key={r.id} className="rounded-lg border border-border/60 p-2.5 space-y-2"
|
||||
style={r.enabled ? preview(r.color) : undefined}>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={r.enabled} onCheckedChange={(c) => patchRule(r.id, { enabled: !!c })} />
|
||||
<span className="font-mono text-xs text-muted-foreground">{i + 1}.</span>
|
||||
<span className="font-medium">{t(LABELS[r.id] ?? r.id)}</span>
|
||||
</label>
|
||||
{/* Which channels this category looks at. None ticked = all of
|
||||
them, which is what an unnarrowed rule should mean. */}
|
||||
{r.enabled && r.id !== 'worked' && (
|
||||
<div className="flex items-center gap-3 flex-wrap pl-6 text-xs">
|
||||
{CHANNELS.map((c) => {
|
||||
const on = !r.channels?.length || r.channels.includes(c);
|
||||
return (
|
||||
<label key={c} className="flex items-center gap-1.5 cursor-pointer">
|
||||
<Checkbox checked={on} onCheckedChange={(v) => {
|
||||
const cur = r.channels?.length ? r.channels : [...CHANNELS];
|
||||
const next = v ? [...new Set([...cur, c])] : cur.filter((x) => x !== c);
|
||||
patchRule(r.id, { channels: next });
|
||||
}} />
|
||||
{CHANNEL_LABELS[c]?.startsWith('appr.') ? t(CHANNEL_LABELS[c]) : CHANNEL_LABELS[c]}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{r.enabled && (
|
||||
<div className="flex items-center gap-1.5 flex-wrap pl-6">
|
||||
{PALETTE.map((c) => (
|
||||
<button key={c} type="button" title={c}
|
||||
onClick={() => patchRule(r.id, { color: c })}
|
||||
className={cn('size-6 rounded-md border-2 transition-transform hover:scale-110',
|
||||
r.color.toLowerCase() === c ? 'border-foreground' : 'border-transparent')}
|
||||
style={{ backgroundColor: c }} />
|
||||
))}
|
||||
<input type="color" value={r.color} title={t('appr.custom')}
|
||||
onChange={(e) => patchRule(r.id, { color: e.target.value })}
|
||||
className="size-6 rounded-md border border-border bg-transparent p-0 cursor-pointer" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -38,6 +38,9 @@ type SpotStatusEntry = {
|
||||
new_county?: boolean;
|
||||
new_pota?: boolean;
|
||||
new_pfx?: boolean;
|
||||
// Whether the station uploads to LoTW. Not a status — it says nothing about
|
||||
// whether the spot is worth chasing — so it is drawn as a badge, never a colour.
|
||||
lotw?: boolean;
|
||||
};
|
||||
|
||||
// The extra markers are ORTHOGONAL to the entity status: a spot can be a worked
|
||||
@@ -79,6 +82,8 @@ interface Props {
|
||||
// globally from the band-map tab toolbar.
|
||||
hideDigital?: boolean;
|
||||
fitToBand?: boolean;
|
||||
// Mark stations that upload to LoTW (Settings → Appearance).
|
||||
showLotw?: boolean;
|
||||
// keyNav enables Ctrl+↑ / Ctrl+↓ to hop to the next spot above / below the rig
|
||||
// frequency (and tune to it). Only the docked Main-view band map sets this, so
|
||||
// the multi-band Band Map tab (several maps) doesn't fight over the shortcut.
|
||||
@@ -227,7 +232,7 @@ const BOT_PAD = 14; // the top-most freq label isn't clipped at y=0
|
||||
// last; ties broken by closeness to the rig freq).
|
||||
const MAX_VISIBLE_SPOTS = 30;
|
||||
|
||||
export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide, hideDigital = false, fitToBand = false, keyNav = false }: Props) {
|
||||
export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide, hideDigital = false, fitToBand = false, keyNav = false, showLotw = false }: Props) {
|
||||
const { t } = useI18n();
|
||||
|
||||
// The two display options are applied ONCE here, on the whole map, so the
|
||||
@@ -649,6 +654,15 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
|
||||
})()}
|
||||
<span className="flex items-center gap-1.5 px-2 font-mono text-[11px] font-bold leading-none">
|
||||
<span>{p.spot.dx_call}</span>
|
||||
{/* Same badge the cluster list draws, for the same reason: the
|
||||
muted-blue of a confirmation, never a status colour —
|
||||
whether a station uploads to LoTW says nothing about
|
||||
whether the spot is worth chasing. A glyph rather than a
|
||||
colour also keeps it off the channel the statuses use. */}
|
||||
{showLotw && entry?.lotw && (
|
||||
<span title={t('clu.lotwBadge')} className="text-[9px] font-normal rounded px-1 py-px"
|
||||
style={{ background: 'color-mix(in srgb, var(--info) 22%, transparent)', color: 'var(--info)' }}>L</span>
|
||||
)}
|
||||
{mode && (
|
||||
<span className="text-[9px] font-normal text-current/70 bg-current/10 rounded px-1 py-px">
|
||||
{mode}
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface DetailsState {
|
||||
srx_string?: string;
|
||||
stx_string?: string;
|
||||
email: string;
|
||||
web?: string;
|
||||
// Award references for the contacted station (set via the Awards tab picker).
|
||||
// Semicolon-delimited "AWARD@REF" entries, e.g. "POTA@FR-11553;IOTA@EU-064".
|
||||
// App.tsx maps these back to pota_ref/sota_ref/iota when saving the QSO.
|
||||
@@ -498,8 +499,14 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth,
|
||||
<Field label="STX">
|
||||
<Input value={details.stx_string ?? ''} placeholder={t('detp.sentExchangePh')} onChange={(e) => onChange({ stx_string: e.target.value })} />
|
||||
</Field>
|
||||
<Field label={t('detp.contactedEmail')} span={3}>
|
||||
<Input value={details.email} placeholder="[email protected]" onChange={(e) => onChange({ email: e.target.value })} />
|
||||
{/* No placeholder: a greyed-out sample address in an empty field reads
|
||||
as a value the lookup found, and that is exactly the field an
|
||||
operator checks to see whether it found one. */}
|
||||
<Field label={t('detp.contactedEmail')} span={2}>
|
||||
<Input value={details.email} onChange={(e) => onChange({ email: e.target.value })} />
|
||||
</Field>
|
||||
<Field label={t('detp.contactedWeb')}>
|
||||
<Input value={details.web ?? ''} onChange={(e) => onChange({ web: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { gridToLatLon, pathBetweenLatLon } from '@/lib/maidenhead';
|
||||
import { rowStyleFor, type RowColorSettings } from '@/lib/rowColors';
|
||||
|
||||
// Register every Community feature once. v32+ requires explicit registration;
|
||||
// AllCommunityModule keeps it simple and pulls in sort/filter/resize/reorder/
|
||||
@@ -80,6 +81,8 @@ type Props = {
|
||||
// One column per defined award; the cell shows the reference this QSO counts
|
||||
// for (from row.award_refs[CODE], attached by the parent). Hidden by default.
|
||||
awardCols?: { code: string; name: string }[];
|
||||
// Whole-row colouring by QSL / LoTW status (Settings → Appearance).
|
||||
rowColors?: RowColorSettings | null;
|
||||
};
|
||||
|
||||
const BASE_COLSTATE_KEY = 'hamlog.qsoColState.v2';
|
||||
@@ -132,6 +135,10 @@ function qsoDistanceKm(d: any, myGrid?: string): number | undefined {
|
||||
|
||||
export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
|
||||
// ── QSO basics ──
|
||||
// Position in the log, oldest = 1. Filled by the backend over the WHOLE log,
|
||||
// so it does not change when a filter narrows what is shown.
|
||||
{ group: 'QSO', label: t('rqg.c.number'), colId: 'number', headerName: t('rqg.h.number'), field: 'number' as any, width: 80, type: 'rightAligned', cellClass: 'font-mono',
|
||||
comparator: (a, b) => (a ?? 0) - (b ?? 0) },
|
||||
{ group: 'QSO', label: t('rqg.c.qso_date'), colId: 'qso_date', headerName: t('rqg.c.qso_date'), field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value), sort: 'desc', defaultVisible: true },
|
||||
{ group: 'QSO', label: t('rqg.c.qso_date_off'), colId: 'qso_date_off', headerName: t('rqg.c.qso_date_off'), field: 'qso_date_off' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value) },
|
||||
{ group: 'QSO', label: t('rqg.c.callsign'), colId: 'callsign', headerName: t('rqg.c.callsign'), field: 'callsign' as any, width: 110, cellClass: 'font-mono font-semibold', defaultVisible: true },
|
||||
@@ -299,7 +306,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 }: Props) {
|
||||
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) {
|
||||
const { t } = useI18n();
|
||||
const gridRef = useRef<any>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
@@ -696,6 +703,7 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
|
||||
animateRows={false}
|
||||
suppressCellFocus
|
||||
getRowId={(p) => String((p.data as any).id)}
|
||||
getRowStyle={(p) => rowStyleFor(p.data, rowColors ?? null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -128,7 +128,9 @@ export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLa
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-center p-2 min-h-0">
|
||||
{/* flex-col: the readout goes BELOW the dial. This wrapper was a row, so a
|
||||
sibling of the <svg> landed beside it. */}
|
||||
<div className="flex flex-col items-center justify-center p-2 min-h-0">
|
||||
<svg
|
||||
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
||||
className={onGoto ? 'cursor-pointer select-none' : 'select-none'}
|
||||
@@ -188,6 +190,31 @@ export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLa
|
||||
); })}
|
||||
<circle cx={C} cy={C} r={3.5} fill="#15803d" stroke="#fff" strokeWidth={1} />
|
||||
</svg>
|
||||
|
||||
{/* Short and long path to the DX, in figures.
|
||||
The bezel already carries the short path as a red marker, but a
|
||||
marker is a direction, not a number — the same pair sits in the
|
||||
status bar at 10px and operators reported not being able to read it.
|
||||
Here because it belongs to the compass: every place that draws one
|
||||
gets the readout, instead of each caller inventing its own.
|
||||
Clickable when the caller can turn, like the status bar's. */}
|
||||
<div className="flex gap-1.5 mt-2 font-mono w-full">
|
||||
{([['SP', bearing ?? null], ['LP', bearing == null ? null : (bearing + 180) % 360]] as const).map(([lbl, az]) => (
|
||||
<button key={lbl} type="button" disabled={az == null || !onGoto}
|
||||
onClick={() => { if (az != null && onGoto) onGoto(Math.round(az)); }}
|
||||
title={az == null ? '' : `${lbl} ${Math.round(az)}°`}
|
||||
className={
|
||||
'flex-1 rounded-md border py-1 text-xs font-semibold tabular-nums transition-colors ' +
|
||||
(az == null
|
||||
? 'border-border text-muted-foreground/50 cursor-not-allowed'
|
||||
: onGoto
|
||||
? 'border-info-border text-info-muted-foreground hover:bg-info-muted cursor-pointer'
|
||||
: 'border-border text-muted-foreground cursor-default')
|
||||
}>
|
||||
{lbl} {az == null ? '—' : `${Math.round(az)}°`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -52,7 +52,7 @@ import {
|
||||
GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile,
|
||||
GetRelayAuto, SaveRelayAuto, GetStationDevices,
|
||||
GetAwardDefs, GetTrackedAwards, SaveTrackedAwards,
|
||||
GetBandOpenSettings, SaveBandOpenSettings, GetPSKReporterStatus,
|
||||
GetBandOpenSettings, SaveBandOpenSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetGridCacheStatus, GetSpotTTLMinutes, SetSpotTTLMinutes,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
import type { profile as profileModels } from '../../wailsjs/go/models';
|
||||
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||
@@ -77,6 +77,7 @@ import { getDateFormat, setDateFormat, type DateFormat } from '@/lib/dateFormat'
|
||||
import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n';
|
||||
import { useTheme, CONCRETE_THEMES, type ThemeChoice } from '@/lib/theme';
|
||||
import { OperatingPanel } from '@/components/OperatingPanel';
|
||||
import { AppearancePanel } from '@/components/AppearancePanel';
|
||||
import { UDPIntegrationsPanel } from '@/components/UDPIntegrationsPanel';
|
||||
|
||||
type LookupSettings = LookupSettingsForm;
|
||||
@@ -176,6 +177,7 @@ interface Props {
|
||||
`disabled: true` greys them out and shows the "coming soon" placeholder. */
|
||||
type SectionId =
|
||||
| 'general'
|
||||
| 'appearance'
|
||||
| 'email'
|
||||
| 'station'
|
||||
| 'profiles'
|
||||
@@ -266,6 +268,7 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
|
||||
{
|
||||
kind: 'group', label: t('nav.software'), icon: Cog, defaultOpen: true, children: [
|
||||
{ kind: 'item', label: t('sec.general'), id: 'general' },
|
||||
{ kind: 'item', label: t('sec.appearance'), id: 'appearance' },
|
||||
{ kind: 'item', label: t('sec.email'), id: 'email' },
|
||||
{ kind: 'item', label: t('sec.lookup'), id: 'lookup' },
|
||||
{ kind: 'group', label: t('nav.lists'), icon: Database, defaultOpen: true, children: [
|
||||
@@ -290,7 +293,7 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
|
||||
// Map section id → i18n key (breadcrumb / placeholders).
|
||||
const SECTION_KEY: Partial<Record<SectionId, string>> = {
|
||||
station: 'sec.station', profiles: 'sec.profiles', operating: 'sec.operating', confirmations: 'sec.confirmations',
|
||||
'external-services': 'sec.external', lookup: 'sec.lookup', 'lists-bands': 'sec.bands', 'lists-modes': 'sec.modes',
|
||||
'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',
|
||||
webpublish: 'sec.webpublish',
|
||||
@@ -1221,6 +1224,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
hamqth_user: '', hamqth_password: '',
|
||||
primary: '', failsafe: '',
|
||||
download_images: false,
|
||||
qrz_prefer_nickname: false,
|
||||
cache_ttl_days: 30,
|
||||
});
|
||||
// Per-provider Test state — keeps the success/error feedback adjacent
|
||||
@@ -1438,13 +1442,15 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
tqsl_path: string; station_location: string; key_password: string;
|
||||
upload_flags: string[]; write_log: boolean;
|
||||
auto_upload: boolean; upload_mode: string;
|
||||
// HRDLog only: publish the live frequency/mode/rig on hrdlog.net.
|
||||
on_air?: boolean;
|
||||
};
|
||||
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg; eqsl: ExtServiceCfg; cloudlog: ExtServiceCfg; delete_remote?: boolean };
|
||||
const emptyExtCfg = (): ExtServiceCfg => ({
|
||||
api_key: '', url: '', station_id: '', email: '', username: '', password: '', callsign: '', code: '', qth_nickname: '',
|
||||
force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '',
|
||||
upload_flags: ['N', 'R'], write_log: false,
|
||||
auto_upload: false, upload_mode: 'immediate',
|
||||
auto_upload: false, upload_mode: 'immediate', on_air: false,
|
||||
});
|
||||
const [extSvc, setExtSvc] = useState<ExternalServices>({
|
||||
qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(), eqsl: emptyExtCfg(), cloudlog: emptyExtCfg(), delete_remote: false,
|
||||
@@ -1552,6 +1558,10 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
// has side effects there — adding the RBN nodes, bringing the PSK Reporter
|
||||
// feed up or down — so the write has to go where those live.
|
||||
const [bandOpen, setBandOpen] = useState<any>({ enabled: false, bands: [], available: [] });
|
||||
const [chaseGrids, setChaseGrids] = useState(false);
|
||||
const [spotTTL, setSpotTTL] = useState(0);
|
||||
const [spotTTLText, setSpotTTLText] = useState('0');
|
||||
const [gridStat, setGridStat] = useState<any>(null);
|
||||
const [pskrStatus, setPskrStatus] = useState<any>(null);
|
||||
const saveBandOpen = async (next: any) => {
|
||||
setBandOpen(next);
|
||||
@@ -1560,11 +1570,14 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try { setBandOpen(await GetBandOpenSettings()); } catch { /* defaults stand */ }
|
||||
try { setChaseGrids(await GetChaseNewGrids()); } catch { /* defaults stand */ }
|
||||
try { const n = await GetSpotTTLMinutes(); setSpotTTL(n); setSpotTTLText(String(n)); } catch { /* defaults stand */ }
|
||||
})();
|
||||
// Poll the feed while the panel is open: a live count is the only thing that
|
||||
// distinguishes "connected" from "connected and receiving nothing".
|
||||
const t = window.setInterval(async () => {
|
||||
try { setPskrStatus(await GetPSKReporterStatus()); } catch { /* ignore */ }
|
||||
try { setGridStat(await GetGridCacheStatus()); } catch { /* ignore */ }
|
||||
}, 3000);
|
||||
return () => window.clearInterval(t);
|
||||
}, []);
|
||||
@@ -2302,6 +2315,23 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* QRZ only. HamQTH's <nick> already fills the Name field with the
|
||||
on-air name, so the same switch there would toggle a behaviour it
|
||||
has no way to turn off. */}
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer mt-3">
|
||||
<Checkbox
|
||||
checked={!!(lookup as any).qrz_prefer_nickname}
|
||||
onCheckedChange={(c) => setLookup((s) => ({ ...s, qrz_prefer_nickname: !!c } as any))}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
{t('lk.qrzNickname')}
|
||||
<span className="block text-xs text-muted-foreground mt-0.5">
|
||||
{t('lk.qrzNicknameHint')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-border">
|
||||
@@ -4227,6 +4257,52 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
things set up once. A preferences dialog you reopen every ten minutes
|
||||
is a filter in the wrong place. */}
|
||||
|
||||
{/* Grid chasing. Here rather than in the filter panel because it is set
|
||||
up once: it decides whether locators learnt from decodes are KEPT
|
||||
across restarts, not what the list shows right now. */}
|
||||
{/* Spot lifetime. Applies to the cluster list AND every band map: the
|
||||
spots are removed from the shared list rather than hidden, so the
|
||||
setting cannot mean one thing in one view and another elsewhere. */}
|
||||
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<span className="text-sm">{t('clu.spotTtl')}</span>
|
||||
<div className="inline-flex rounded-md border border-border overflow-hidden text-xs">
|
||||
{[0, 5, 10, 15, 30, 60].map((v) => (
|
||||
<button key={v} type="button"
|
||||
onClick={() => { setSpotTTL(v); setSpotTTLText(String(v)); SetSpotTTLMinutes(v).catch(() => {}); }}
|
||||
className={cn('px-2.5 py-1.5 font-medium', spotTTL === v ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
|
||||
{v === 0 ? t('clu.spotTtlNever') : `${v}′`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Raw text in local state: binding the input straight to the
|
||||
clamped number makes an empty field impossible to type into. */}
|
||||
<Input className="h-8 w-20" value={spotTTLText}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value.replace(/[^0-9]/g, '');
|
||||
setSpotTTLText(raw);
|
||||
const n = Math.min(720, parseInt(raw, 10) || 0);
|
||||
setSpotTTL(n);
|
||||
SetSpotTTLMinutes(n).catch(() => {});
|
||||
}} />
|
||||
<span className="text-xs text-muted-foreground">{t('clu.spotTtlHint')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={chaseGrids} className="mt-0.5"
|
||||
onCheckedChange={(c) => { setChaseGrids(!!c); SetChaseNewGrids(!!c).catch(() => {}); }} />
|
||||
<span>{t('clu.chaseGrids')} <span className="text-xs text-muted-foreground">{t('clu.chaseGridsHint')}</span></span>
|
||||
</label>
|
||||
{chaseGrids && (
|
||||
<p className="pl-6 text-xs text-muted-foreground">
|
||||
{t('clu.chaseGridsStat', { n: gridStat?.known ?? 0, p: gridStat?.pending ?? 0 })}
|
||||
{pskrStatus?.running ? ` · ${t('bo.feedUp', { n: pskrStatus.received ?? 0 })}` : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Band-opening watch. It lives HERE, with the cluster nodes, because
|
||||
switching it on adds two of them — the operator should see that
|
||||
happen where it happens rather than find nodes they did not add. */}
|
||||
@@ -4945,6 +5021,20 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
{t('es.autoUpload')}
|
||||
</label>
|
||||
|
||||
{/* ON AIR is a live status, not an upload: it needs the rig
|
||||
readable, not a QSO. Its own switch beside the upload one
|
||||
rather than folded into it. */}
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<Checkbox className="mt-0.5"
|
||||
checked={!!(hrdlog as any).on_air}
|
||||
onCheckedChange={(c) => setHrdlog({ on_air: !!c } as any)}
|
||||
/>
|
||||
<span>
|
||||
{t('es.hrdOnAir')}
|
||||
<span className="block text-xs text-muted-foreground mt-0.5">{t('es.hrdOnAirHint')}</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||
<Label className="text-sm">{t('es.uploadTiming')}</Label>
|
||||
<Select
|
||||
@@ -6142,6 +6232,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
// Map sections to their content + icon (for placeholder).
|
||||
const PANELS: Record<SectionId, () => JSX.Element> = {
|
||||
general: GeneralPanel,
|
||||
appearance: () => <AppearancePanel />,
|
||||
email: EmailPanel,
|
||||
station: StationPanel,
|
||||
profiles: ProfilesPanel,
|
||||
|
||||
@@ -77,6 +77,8 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato
|
||||
title={hd.ok ? t('station.online') : t('station.rotatorNoRead')} />
|
||||
</div>
|
||||
<div className="p-3 flex gap-4 items-start">
|
||||
{/* The SP/LP readout lives INSIDE RotorCompass, so every compass in the
|
||||
app carries it rather than each caller drawing its own. */}
|
||||
<RotorCompass
|
||||
bearing={bearing ?? null}
|
||||
headings={hd.ok ? [hd.azimuth] : []}
|
||||
|
||||
+15
-15
File diff suppressed because one or more lines are too long
@@ -0,0 +1,93 @@
|
||||
// Row colouring for the log grid, by QSL status.
|
||||
//
|
||||
// Four categories, each scoped to the channels the operator cares about —
|
||||
// paper QSL, LoTW, eQSL, QRZ.com. The rules are ORDERED and the first match
|
||||
// wins, because a contact is usually several of them at once.
|
||||
|
||||
export type RowColorRule = {
|
||||
id: string;
|
||||
color: string;
|
||||
enabled: boolean;
|
||||
channels?: string[]; // empty = every channel
|
||||
};
|
||||
export type RowColorSettings = {
|
||||
enabled: boolean;
|
||||
style?: 'bar' | 'tint' | 'both';
|
||||
intensity?: number;
|
||||
bandmap_lotw?: boolean;
|
||||
rules: RowColorRule[];
|
||||
};
|
||||
|
||||
export const CHANNELS = ['qsl', 'lotw', 'eqsl', 'qrz'] as const;
|
||||
|
||||
// The two QSO fields behind each channel. QRZ.com and Club Log call theirs an
|
||||
// "upload status" rather than a QSL flag, but they carry the same Y / R letters.
|
||||
const FIELDS: Record<string, { sent: string; rcvd: string }> = {
|
||||
qsl: { sent: 'qsl_sent', rcvd: 'qsl_rcvd' },
|
||||
lotw: { sent: 'lotw_sent', rcvd: 'lotw_rcvd' },
|
||||
eqsl: { sent: 'eqsl_sent', rcvd: 'eqsl_rcvd' },
|
||||
qrz: { sent: 'qrzcom_qso_upload_status', rcvd: 'qrzcom_qso_download_status' },
|
||||
};
|
||||
|
||||
// ADIF QSL fields are single letters. Y is the only one that means "yes";
|
||||
// R (requested) and Q (queued) mean it has not gone out yet — a different state,
|
||||
// and the one an operator looks for when deciding what to send.
|
||||
const yes = (v: any) => String(v ?? '').trim().toUpperCase() === 'Y';
|
||||
const owed = (v: any) => {
|
||||
const s = String(v ?? '').trim().toUpperCase();
|
||||
return s === 'R' || s === 'Q';
|
||||
};
|
||||
|
||||
const chansOf = (r: RowColorRule): readonly string[] =>
|
||||
r.channels && r.channels.length ? r.channels : CHANNELS;
|
||||
|
||||
function ruleMatches(q: any, r: RowColorRule): boolean {
|
||||
const cs = chansOf(r);
|
||||
switch (r.id) {
|
||||
case 'confirmed':
|
||||
return cs.some((c) => yes(q[FIELDS[c]?.rcvd]));
|
||||
case 'sent':
|
||||
return cs.some((c) => yes(q[FIELDS[c]?.sent]));
|
||||
case 'to_send':
|
||||
return cs.some((c) => owed(q[FIELDS[c]?.sent]));
|
||||
case 'worked':
|
||||
// The catch-all: nothing sent, nothing asked for, nothing back.
|
||||
return !CHANNELS.some((c) => yes(q[FIELDS[c].rcvd]) || yes(q[FIELDS[c].sent]) || owed(q[FIELDS[c].sent]));
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Walks the rules IN ORDER — the order is the priority, and the settings panel
|
||||
// shows it numbered so it can be read rather than guessed.
|
||||
export function matchRowRule(q: any, cfg: RowColorSettings | null): RowColorRule | null {
|
||||
if (!q || !cfg?.rules) return null;
|
||||
for (const r of cfg.rules) {
|
||||
if (r.enabled && ruleMatches(q, r)) return r;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// The colour is never a fill by default.
|
||||
//
|
||||
// A log where nearly every contact has SOME QSL state ends up with every row
|
||||
// painted, and colour that is always present stops being information. The
|
||||
// default is a stripe down the left edge; a tint is offered at a chosen strength.
|
||||
export function rowStyleFor(q: any, cfg: RowColorSettings | null): Record<string, string> | undefined {
|
||||
if (!cfg?.enabled) return undefined;
|
||||
const rule = matchRowRule(q, cfg);
|
||||
if (!rule?.color) return undefined;
|
||||
|
||||
const style = cfg.style ?? 'bar';
|
||||
const pct = Math.max(5, Math.min(45, cfg.intensity ?? 12));
|
||||
const out: Record<string, string> = {};
|
||||
if (style === 'tint' || style === 'both') {
|
||||
out.backgroundColor = `color-mix(in srgb, ${rule.color} ${pct}%, transparent)`;
|
||||
}
|
||||
if (style === 'bar' || style === 'both') {
|
||||
// inset shadow rather than a border: a border would shift the cells three
|
||||
// pixels on coloured rows only, and the columns would stop lining up.
|
||||
out.boxShadow = `inset 3px 0 0 ${rule.color}`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -74,3 +74,16 @@ export function applySpotDisplay<T extends Entry>(s: T, o: SpotDisplayOptions):
|
||||
}
|
||||
return e;
|
||||
}
|
||||
|
||||
// spotIsWorked answers "does this spot bring me nothing?", which is what the
|
||||
// "hide worked" filter is really asking.
|
||||
//
|
||||
// The extra markers are ORTHOGONAL to the entity status — a spot can be a worked
|
||||
// entity AND a new park, prefix, county or grid — so they win. Without that, a
|
||||
// new prefix in an entity worked years ago vanished the moment the filter went
|
||||
// on, which is the opposite of what an operator hunting prefixes wants.
|
||||
export function spotIsWorked(e: Entry): boolean {
|
||||
if (!e) return false;
|
||||
if (e.new_pota || e.new_county || e.new_pfx || e.new_grid) return false;
|
||||
return !!e.worked_call || e.status === 'worked';
|
||||
}
|
||||
|
||||
@@ -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.24.7';
|
||||
export const APP_VERSION = '0.25.0';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+16
@@ -404,6 +404,8 @@ export function GetCatalogCodes():Promise<Array<string>>;
|
||||
|
||||
export function GetChangelog():Promise<Array<main.ChangelogEntry>>;
|
||||
|
||||
export function GetChaseNewGrids():Promise<boolean>;
|
||||
|
||||
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
|
||||
|
||||
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
|
||||
@@ -440,6 +442,8 @@ export function GetFlexBandPower():Promise<Record<string, main.FlexBandPower>>;
|
||||
|
||||
export function GetFlexState():Promise<cat.FlexTXState>;
|
||||
|
||||
export function GetGridCacheStatus():Promise<main.GridCacheStatus>;
|
||||
|
||||
export function GetIcomState():Promise<cat.IcomTXState>;
|
||||
|
||||
export function GetListsSettings():Promise<main.ListsSettings>;
|
||||
@@ -488,6 +492,8 @@ export function GetRotatorHeading():Promise<main.RotatorHeading>;
|
||||
|
||||
export function GetRotators():Promise<Array<main.RotatorDevice>>;
|
||||
|
||||
export function GetRowColors():Promise<main.RowColorSettings>;
|
||||
|
||||
export function GetSPEStatus():Promise<spe.Status>;
|
||||
|
||||
export function GetScpStatus():Promise<main.ScpStatus>;
|
||||
@@ -500,6 +506,8 @@ export function GetSlotStats():Promise<qso.SlotStats>;
|
||||
|
||||
export function GetSolarData():Promise<solar.Data>;
|
||||
|
||||
export function GetSpotTTLMinutes():Promise<number>;
|
||||
|
||||
export function GetStartupStatus():Promise<main.StartupStatus>;
|
||||
|
||||
export function GetStationDevices():Promise<Array<main.StationDevice>>;
|
||||
@@ -924,6 +932,8 @@ export function SaveRelayAuto(arg1:main.RelayAutoConfig):Promise<void>;
|
||||
|
||||
export function SaveRotators(arg1:Array<main.RotatorDevice>):Promise<void>;
|
||||
|
||||
export function SaveRowColors(arg1:main.RowColorSettings):Promise<void>;
|
||||
|
||||
export function SaveSelfSpotSettings(arg1:main.SelfSpotSettings):Promise<void>;
|
||||
|
||||
export function SaveStationDevices(arg1:Array<main.StationDevice>):Promise<void>;
|
||||
@@ -970,12 +980,16 @@ export function SetCIVTrace(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetCWDecoderPitch(arg1:number):Promise<void>;
|
||||
|
||||
export function SetChaseNewGrids(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetClublogMostWantedEnabled(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetClusterAutoConnect(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetCompactHeight(arg1:number):Promise<void>;
|
||||
|
||||
export function SetCompactMode(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
|
||||
@@ -990,6 +1004,8 @@ export function SetPassphrase(arg1:string):Promise<void>;
|
||||
|
||||
export function SetScpEnabled(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetSpotTTLMinutes(arg1:number):Promise<void>;
|
||||
|
||||
export function SetTelemetryEnabled(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetUIPref(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
@@ -750,6 +750,10 @@ export function GetChangelog() {
|
||||
return window['go']['main']['App']['GetChangelog']();
|
||||
}
|
||||
|
||||
export function GetChaseNewGrids() {
|
||||
return window['go']['main']['App']['GetChaseNewGrids']();
|
||||
}
|
||||
|
||||
export function GetChatHistory(arg1) {
|
||||
return window['go']['main']['App']['GetChatHistory'](arg1);
|
||||
}
|
||||
@@ -822,6 +826,10 @@ export function GetFlexState() {
|
||||
return window['go']['main']['App']['GetFlexState']();
|
||||
}
|
||||
|
||||
export function GetGridCacheStatus() {
|
||||
return window['go']['main']['App']['GetGridCacheStatus']();
|
||||
}
|
||||
|
||||
export function GetIcomState() {
|
||||
return window['go']['main']['App']['GetIcomState']();
|
||||
}
|
||||
@@ -918,6 +926,10 @@ export function GetRotators() {
|
||||
return window['go']['main']['App']['GetRotators']();
|
||||
}
|
||||
|
||||
export function GetRowColors() {
|
||||
return window['go']['main']['App']['GetRowColors']();
|
||||
}
|
||||
|
||||
export function GetSPEStatus() {
|
||||
return window['go']['main']['App']['GetSPEStatus']();
|
||||
}
|
||||
@@ -942,6 +954,10 @@ export function GetSolarData() {
|
||||
return window['go']['main']['App']['GetSolarData']();
|
||||
}
|
||||
|
||||
export function GetSpotTTLMinutes() {
|
||||
return window['go']['main']['App']['GetSpotTTLMinutes']();
|
||||
}
|
||||
|
||||
export function GetStartupStatus() {
|
||||
return window['go']['main']['App']['GetStartupStatus']();
|
||||
}
|
||||
@@ -1790,6 +1806,10 @@ export function SaveRotators(arg1) {
|
||||
return window['go']['main']['App']['SaveRotators'](arg1);
|
||||
}
|
||||
|
||||
export function SaveRowColors(arg1) {
|
||||
return window['go']['main']['App']['SaveRowColors'](arg1);
|
||||
}
|
||||
|
||||
export function SaveSelfSpotSettings(arg1) {
|
||||
return window['go']['main']['App']['SaveSelfSpotSettings'](arg1);
|
||||
}
|
||||
@@ -1882,6 +1902,10 @@ export function SetCWDecoderPitch(arg1) {
|
||||
return window['go']['main']['App']['SetCWDecoderPitch'](arg1);
|
||||
}
|
||||
|
||||
export function SetChaseNewGrids(arg1) {
|
||||
return window['go']['main']['App']['SetChaseNewGrids'](arg1);
|
||||
}
|
||||
|
||||
export function SetClublogCtyEnabled(arg1) {
|
||||
return window['go']['main']['App']['SetClublogCtyEnabled'](arg1);
|
||||
}
|
||||
@@ -1894,6 +1918,10 @@ export function SetClusterAutoConnect(arg1) {
|
||||
return window['go']['main']['App']['SetClusterAutoConnect'](arg1);
|
||||
}
|
||||
|
||||
export function SetCompactHeight(arg1) {
|
||||
return window['go']['main']['App']['SetCompactHeight'](arg1);
|
||||
}
|
||||
|
||||
export function SetCompactMode(arg1) {
|
||||
return window['go']['main']['App']['SetCompactMode'](arg1);
|
||||
}
|
||||
@@ -1922,6 +1950,10 @@ export function SetScpEnabled(arg1) {
|
||||
return window['go']['main']['App']['SetScpEnabled'](arg1);
|
||||
}
|
||||
|
||||
export function SetSpotTTLMinutes(arg1) {
|
||||
return window['go']['main']['App']['SetSpotTTLMinutes'](arg1);
|
||||
}
|
||||
|
||||
export function SetTelemetryEnabled(arg1) {
|
||||
return window['go']['main']['App']['SetTelemetryEnabled'](arg1);
|
||||
}
|
||||
|
||||
@@ -1331,6 +1331,7 @@ export namespace extsvc {
|
||||
upload_flags: string[];
|
||||
write_log: boolean;
|
||||
auto_upload: boolean;
|
||||
on_air: boolean;
|
||||
upload_mode: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
@@ -1355,6 +1356,7 @@ export namespace extsvc {
|
||||
this.upload_flags = source["upload_flags"];
|
||||
this.write_log = source["write_log"];
|
||||
this.auto_upload = source["auto_upload"];
|
||||
this.on_air = source["on_air"];
|
||||
this.upload_mode = source["upload_mode"];
|
||||
}
|
||||
}
|
||||
@@ -1441,6 +1443,8 @@ export namespace lookup {
|
||||
cont?: string;
|
||||
email?: string;
|
||||
qsl_via?: string;
|
||||
web?: string;
|
||||
zip?: string;
|
||||
image_url?: string;
|
||||
source: string;
|
||||
// Go type: time
|
||||
@@ -1468,6 +1472,8 @@ export namespace lookup {
|
||||
this.cont = source["cont"];
|
||||
this.email = source["email"];
|
||||
this.qsl_via = source["qsl_via"];
|
||||
this.web = source["web"];
|
||||
this.zip = source["zip"];
|
||||
this.image_url = source["image_url"];
|
||||
this.source = source["source"];
|
||||
this.fetched_at = this.convertValues(source["fetched_at"], null);
|
||||
@@ -2419,6 +2425,22 @@ export namespace main {
|
||||
this.body = source["body"];
|
||||
}
|
||||
}
|
||||
export class GridCacheStatus {
|
||||
enabled: boolean;
|
||||
known: number;
|
||||
pending: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new GridCacheStatus(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.enabled = source["enabled"];
|
||||
this.known = source["known"];
|
||||
this.pending = source["pending"];
|
||||
}
|
||||
}
|
||||
export class ModePreset {
|
||||
name: string;
|
||||
default_rst_sent?: string;
|
||||
@@ -2521,6 +2543,7 @@ export namespace main {
|
||||
primary: string;
|
||||
failsafe: string;
|
||||
download_images: boolean;
|
||||
qrz_prefer_nickname: boolean;
|
||||
cache_ttl_days: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
@@ -2536,6 +2559,7 @@ export namespace main {
|
||||
this.primary = source["primary"];
|
||||
this.failsafe = source["failsafe"];
|
||||
this.download_images = source["download_images"];
|
||||
this.qrz_prefer_nickname = source["qrz_prefer_nickname"];
|
||||
this.cache_ttl_days = source["cache_ttl_days"];
|
||||
}
|
||||
}
|
||||
@@ -2972,6 +2996,64 @@ export namespace main {
|
||||
this.motorized = source["motorized"];
|
||||
}
|
||||
}
|
||||
export class RowColorRule {
|
||||
id: string;
|
||||
color: string;
|
||||
enabled: boolean;
|
||||
channels: string[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new RowColorRule(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.color = source["color"];
|
||||
this.enabled = source["enabled"];
|
||||
this.channels = source["channels"];
|
||||
}
|
||||
}
|
||||
export class RowColorSettings {
|
||||
enabled: boolean;
|
||||
style: string;
|
||||
intensity: number;
|
||||
bandmap_lotw: boolean;
|
||||
configured: boolean;
|
||||
rules: RowColorRule[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new RowColorSettings(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.enabled = source["enabled"];
|
||||
this.style = source["style"];
|
||||
this.intensity = source["intensity"];
|
||||
this.bandmap_lotw = source["bandmap_lotw"];
|
||||
this.configured = source["configured"];
|
||||
this.rules = this.convertValues(source["rules"], RowColorRule);
|
||||
}
|
||||
|
||||
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 ScpStatus {
|
||||
enabled: boolean;
|
||||
count: number;
|
||||
@@ -4186,6 +4268,7 @@ export namespace qso {
|
||||
}
|
||||
export class QSO {
|
||||
id: number;
|
||||
number?: number;
|
||||
callsign: string;
|
||||
// Go type: time
|
||||
qso_date: any;
|
||||
@@ -4326,6 +4409,7 @@ export namespace qso {
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.number = source["number"];
|
||||
this.callsign = source["callsign"];
|
||||
this.qso_date = this.convertValues(source["qso_date"], null);
|
||||
this.qso_date_off = this.convertValues(source["qso_date_off"], null);
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package main
|
||||
|
||||
// HRDLog "ON AIR" — publishing the live frequency, mode and rig on hrdlog.net,
|
||||
// so the site shows "F4BPO is on air 14,074,000 FT8 IC-7300" while the station
|
||||
// is operating.
|
||||
//
|
||||
// The endpoint and its field names come from HRDLog's own library
|
||||
// (github.com/iw1qlh/HRDLOG-net-library), not from reading someone's traffic.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
"hamlog/internal/extsvc"
|
||||
)
|
||||
|
||||
// onAirEvery is how often the status is refreshed. HRDLog drops a station from
|
||||
// the on-air list when it stops hearing from it, so this is a heartbeat, not a
|
||||
// change notification.
|
||||
const onAirEvery = 2 * time.Minute
|
||||
|
||||
// hrdlogOnAirLoop publishes while the option is on and the rig is readable.
|
||||
func (a *App) hrdlogOnAirLoop() {
|
||||
defer func() { _ = recover() }() // never crash the app from a status broadcast
|
||||
t := time.NewTicker(onAirEvery)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
a.publishHRDLogOnAir()
|
||||
}
|
||||
}
|
||||
|
||||
// publishHRDLogOnAir sends one update. Silent when switched off, unconfigured,
|
||||
// or when the rig has nothing to report — an announcement with no frequency
|
||||
// would say the operator is on air on 0 Hz.
|
||||
func (a *App) publishHRDLogOnAir() {
|
||||
cfg := a.loadExternalServices().HRDLog
|
||||
if !cfg.OnAir || strings.TrimSpace(cfg.Callsign) == "" || strings.TrimSpace(cfg.Code) == "" {
|
||||
return
|
||||
}
|
||||
if a.cat == nil {
|
||||
return
|
||||
}
|
||||
st := a.cat.State()
|
||||
if !st.Connected || st.FreqHz <= 0 {
|
||||
return
|
||||
}
|
||||
// The rig NAME as the operator knows it — that string is what the site
|
||||
// displays. The active profile's MyRig first, since it is what every QSO is
|
||||
// stamped with; the CAT backend's own model only when the profile has none.
|
||||
radio := ""
|
||||
if a.profiles != nil {
|
||||
if pr, err := a.profiles.Active(a.ctx); err == nil {
|
||||
radio = strings.TrimSpace(pr.MyRig)
|
||||
}
|
||||
}
|
||||
if radio == "" {
|
||||
radio = strings.TrimSpace(st.Rig)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
msg, err := extsvc.SendHRDLogOnAir(ctx, nil, cfg.Callsign, cfg.Code, st.FreqHz, st.Mode, radio)
|
||||
if err != nil {
|
||||
// Logged, never surfaced: this is a broadcast the operator did not ask
|
||||
// for at this instant, and a toast every two minutes on a flaky link
|
||||
// would be worse than the missing status.
|
||||
applog.Printf("hrdlog on-air: %v (%s)", err, msg)
|
||||
return
|
||||
}
|
||||
applog.Printf("hrdlog on-air: %s %.3f MHz %s (%s) — %s",
|
||||
cfg.Callsign, float64(st.FreqHz)/1e6, st.Mode, radio, msg)
|
||||
}
|
||||
@@ -19,8 +19,8 @@ func TestCharCountLengthRepair(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "cyrillic",
|
||||
wantQTH: "Дзержинск", // 9 chars / 18 bytes, declared 9
|
||||
wantName: "Александр Чайка", // 15 chars / 29 bytes, declared 15
|
||||
wantQTH: "Дзержинск", // 9 chars / 18 bytes, declared 9
|
||||
wantName: "Александр Чайка", // 15 chars / 29 bytes, declared 15
|
||||
adi: "<EOH>\n<CALL:6>UA3TFS<NAME:15>Александр Чайка<QTH:9>Дзержинск<EOR>\n",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package adif
|
||||
|
||||
import "testing"
|
||||
|
||||
// ADIF defines CNTY as "STATE,COUNTY" — "GA,BARROW". OpsLog exported the bare
|
||||
// county, which a receiving logger cannot resolve: county names repeat across
|
||||
// states, and there is a Washington County in thirty of them.
|
||||
func TestADIFCountyFormat(t *testing.T) {
|
||||
for _, tc := range []struct{ state, county, want string }{
|
||||
{"GA", "BARROW", "GA,BARROW"},
|
||||
{"ga", "Barrow", "GA,Barrow"}, // the state is a code, upper-cased; the name is not touched
|
||||
{"", "BARROW", "BARROW"}, // no state known: a bare name beats nothing
|
||||
{"GA", "", ""}, // no county: nothing to write
|
||||
{"GA", "GA,BARROW", "GA,BARROW"}, // already formatted — never double the prefix
|
||||
{" GA ", " BARROW ", "GA,BARROW"},
|
||||
} {
|
||||
if got := adifCounty(tc.state, tc.county); got != tc.want {
|
||||
t.Errorf("adifCounty(%q, %q) = %q, want %q", tc.state, tc.county, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// And the mirror: a file from a logger that follows the standard must fill both
|
||||
// columns, while a file carrying the bare county still imports unchanged.
|
||||
func TestSplitADIFCounty(t *testing.T) {
|
||||
for _, tc := range []struct{ in, county, state string }{
|
||||
{"GA,BARROW", "BARROW", "GA"},
|
||||
{"ga, Barrow", "Barrow", "GA"},
|
||||
{"BARROW", "BARROW", ""}, // the old OpsLog form, and many other loggers
|
||||
{"", "", ""},
|
||||
{" ", "", ""},
|
||||
} {
|
||||
c, s := splitADIFCounty(tc.in)
|
||||
if c != tc.county || s != tc.state {
|
||||
t.Errorf("splitADIFCounty(%q) = (%q, %q), want (%q, %q)", tc.in, c, s, tc.county, tc.state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Round trip: what OpsLog writes, OpsLog reads back to the same two values.
|
||||
func TestCountyRoundTrip(t *testing.T) {
|
||||
c, s := splitADIFCounty(adifCounty("GA", "BARROW"))
|
||||
if c != "BARROW" || s != "GA" {
|
||||
t.Errorf("round trip gave (%q, %q), want (BARROW, GA)", c, s)
|
||||
}
|
||||
}
|
||||
+29
-7
@@ -15,9 +15,9 @@ import (
|
||||
|
||||
// ExportResult summarises an ADIF export for the UI.
|
||||
type ExportResult struct {
|
||||
Path string `json:"path"`
|
||||
Count int `json:"count"`
|
||||
SizeKB int64 `json:"size_kb"`
|
||||
Path string `json:"path"`
|
||||
Count int `json:"count"`
|
||||
SizeKB int64 `json:"size_kb"`
|
||||
}
|
||||
|
||||
// Exporter streams every QSO in a repo to an ADIF (.adi) file.
|
||||
@@ -221,7 +221,7 @@ func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]
|
||||
w("VUCC_GRIDS", q.VUCCGrids)
|
||||
w("COUNTRY", q.Country)
|
||||
w("STATE", q.State)
|
||||
w("CNTY", q.County)
|
||||
w("CNTY", adifCounty(q.State, q.County))
|
||||
wi("DXCC", q.DXCC)
|
||||
w("CONT", q.Continent)
|
||||
wi("CQZ", q.CQZ)
|
||||
@@ -285,7 +285,7 @@ func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]
|
||||
w("MY_GRIDSQUARE_EXT", q.MyGridExt)
|
||||
w("MY_COUNTRY", q.MyCountry)
|
||||
w("MY_STATE", q.MyState)
|
||||
w("MY_CNTY", q.MyCounty)
|
||||
w("MY_CNTY", adifCounty(q.MyState, q.MyCounty))
|
||||
w("MY_IOTA", q.MyIOTA)
|
||||
w("MY_SOTA_REF", q.MySOTARef)
|
||||
w("MY_POTA_REF", q.MyPOTARef)
|
||||
@@ -391,10 +391,10 @@ var parentMode = map[string]string{
|
||||
"ISCAT": "MFSK", "Q65": "MFSK", "FST4": "MFSK", "FST4W": "MFSK",
|
||||
"MFSK16": "MFSK", "MFSK32": "MFSK", "MFSK64": "MFSK", "MFSK128": "MFSK",
|
||||
"OLIVIA": "MFSK",
|
||||
"PSK31": "PSK", "PSK63": "PSK", "PSK125": "PSK", "PSK250": "PSK", "PSK500": "PSK",
|
||||
"PSK31": "PSK", "PSK63": "PSK", "PSK125": "PSK", "PSK250": "PSK", "PSK500": "PSK",
|
||||
"QPSK31": "PSK", "QPSK63": "PSK", "QPSK125": "PSK", "QPSK250": "PSK", "QPSK500": "PSK",
|
||||
"FREEDV": "DIGITALVOICE",
|
||||
"VARA": "DYNAMIC", "VARA HF": "DYNAMIC", "VARA FM": "DYNAMIC", "VARAC": "DYNAMIC",
|
||||
"VARA": "DYNAMIC", "VARA HF": "DYNAMIC", "VARA FM": "DYNAMIC", "VARAC": "DYNAMIC",
|
||||
"THOR4": "THOR", "THOR8": "THOR", "THOR16": "THOR", "THOR32": "THOR",
|
||||
"DOMINOF": "DOMINO", "DOMINOEX": "DOMINO",
|
||||
"HELL80": "HELL", "FMHELL": "HELL",
|
||||
@@ -413,3 +413,25 @@ func modeForExport(mode, submode string) (string, string) {
|
||||
}
|
||||
return mode, ""
|
||||
}
|
||||
|
||||
// adifCounty renders CNTY the way ADIF specifies: "STATE,COUNTY", e.g.
|
||||
// "GA,BARROW".
|
||||
//
|
||||
// OpsLog stores the two apart, and exported only the county — which a receiving
|
||||
// logger cannot resolve, since county names repeat across states (there is a
|
||||
// Washington County in thirty of them). The award engine here was never
|
||||
// affected because it reads the columns, not the ADIF; the damage was to
|
||||
// everyone we send a file to.
|
||||
//
|
||||
// The county alone is still written when no state is known: a bare name is
|
||||
// worth more than nothing, and inventing a prefix would be worse than either.
|
||||
// A county that ALREADY carries a comma is passed through — it has been
|
||||
// formatted once, and doubling the prefix would corrupt it.
|
||||
func adifCounty(state, county string) string {
|
||||
c := strings.TrimSpace(county)
|
||||
s := strings.TrimSpace(state)
|
||||
if c == "" || s == "" || strings.Contains(c, ",") {
|
||||
return c
|
||||
}
|
||||
return strings.ToUpper(s) + "," + c
|
||||
}
|
||||
|
||||
+13
-13
@@ -16,23 +16,23 @@ import "strings"
|
||||
type FieldKind string
|
||||
|
||||
const (
|
||||
KindText FieldKind = "text" // String / IntlString / MultilineString
|
||||
KindNumber FieldKind = "number" // Number / PositiveInteger
|
||||
KindDate FieldKind = "date" // ADIF Date (YYYYMMDD)
|
||||
KindTime FieldKind = "time" // ADIF Time (HHMMSS / HHMM)
|
||||
KindBool FieldKind = "boolean" // Boolean (Y/N)
|
||||
KindEnum FieldKind = "enum" // Enumeration
|
||||
KindLoc FieldKind = "location" // Location (e.g. "N048 09.000")
|
||||
KindText FieldKind = "text" // String / IntlString / MultilineString
|
||||
KindNumber FieldKind = "number" // Number / PositiveInteger
|
||||
KindDate FieldKind = "date" // ADIF Date (YYYYMMDD)
|
||||
KindTime FieldKind = "time" // ADIF Time (HHMMSS / HHMM)
|
||||
KindBool FieldKind = "boolean" // Boolean (Y/N)
|
||||
KindEnum FieldKind = "enum" // Enumeration
|
||||
KindLoc FieldKind = "location" // Location (e.g. "N048 09.000")
|
||||
)
|
||||
|
||||
// FieldDef describes one ADIF QSO field.
|
||||
type FieldDef struct {
|
||||
Name string `json:"name"` // canonical uppercase ADIF tag
|
||||
Kind FieldKind `json:"kind"` // editor widget hint
|
||||
Category string `json:"category"` // grouping for the UI
|
||||
Promoted bool `json:"promoted"` // has a dedicated QSO column
|
||||
Deprecated bool `json:"deprecated"` // import-only per the spec
|
||||
Intl bool `json:"intl"` // *_INTL UTF-8 variant
|
||||
Name string `json:"name"` // canonical uppercase ADIF tag
|
||||
Kind FieldKind `json:"kind"` // editor widget hint
|
||||
Category string `json:"category"` // grouping for the UI
|
||||
Promoted bool `json:"promoted"` // has a dedicated QSO column
|
||||
Deprecated bool `json:"deprecated"` // import-only per the spec
|
||||
Intl bool `json:"intl"` // *_INTL UTF-8 variant
|
||||
}
|
||||
|
||||
// adifVersion is the ADIF spec version OpsLog targets for import/export.
|
||||
|
||||
+29
-2
@@ -428,7 +428,12 @@ func recordToQSO(rec Record) (qso.QSO, bool) {
|
||||
q.VUCCGrids = strings.ToUpper(rec["vucc_grids"])
|
||||
q.Country = rec["country"]
|
||||
q.State = strings.ToUpper(rec["state"])
|
||||
q.County = rec["cnty"]
|
||||
q.County, _ = splitADIFCounty(rec["cnty"])
|
||||
if st := rec["state"]; strings.TrimSpace(st) == "" {
|
||||
if _, fromCnty := splitADIFCounty(rec["cnty"]); fromCnty != "" {
|
||||
q.State = fromCnty
|
||||
}
|
||||
}
|
||||
if v, ok := parseInt(rec["dxcc"]); ok {
|
||||
q.DXCC = &v
|
||||
}
|
||||
@@ -515,7 +520,12 @@ func recordToQSO(rec Record) (qso.QSO, bool) {
|
||||
q.MyGridExt = strings.ToUpper(rec["my_gridsquare_ext"])
|
||||
q.MyCountry = rec["my_country"]
|
||||
q.MyState = strings.ToUpper(rec["my_state"])
|
||||
q.MyCounty = rec["my_cnty"]
|
||||
q.MyCounty, _ = splitADIFCounty(rec["my_cnty"])
|
||||
if st := rec["my_state"]; strings.TrimSpace(st) == "" {
|
||||
if _, fromCnty := splitADIFCounty(rec["my_cnty"]); fromCnty != "" {
|
||||
q.MyState = fromCnty
|
||||
}
|
||||
}
|
||||
q.MyIOTA = strings.ToUpper(rec["my_iota"])
|
||||
q.MySOTARef = strings.ToUpper(rec["my_sota_ref"])
|
||||
q.MyPOTARef = strings.ToUpper(rec["my_pota_ref"])
|
||||
@@ -698,3 +708,20 @@ var promotableSubmodes = map[string]bool{
|
||||
func submodeSubsumesParent(submode string) bool {
|
||||
return promotableSubmodes[submode]
|
||||
}
|
||||
|
||||
// splitADIFCounty reads CNTY, which ADIF defines as "STATE,COUNTY".
|
||||
//
|
||||
// Returns the county on its own plus the state the field carried, so an import
|
||||
// from a logger that follows the standard fills BOTH columns here. Files that
|
||||
// write the bare county — as OpsLog itself used to — still import unchanged.
|
||||
//
|
||||
// The state from the field never overrides an explicit STATE tag: STATE is the
|
||||
// dedicated field and the more specific statement. It only fills a blank.
|
||||
func splitADIFCounty(cnty string) (county, state string) {
|
||||
cnty = strings.TrimSpace(cnty)
|
||||
i := strings.Index(cnty, ",")
|
||||
if i < 0 {
|
||||
return cnty, ""
|
||||
}
|
||||
return strings.TrimSpace(cnty[i+1:]), strings.ToUpper(strings.TrimSpace(cnty[:i]))
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ func TestPromotedFieldsRoundTrip(t *testing.T) {
|
||||
in := qso.QSO{
|
||||
Callsign: "EA8ABC", Band: "20m", Mode: "SSB",
|
||||
QSODate: time.Date(2026, 6, 6, 12, 0, 0, 0, time.UTC),
|
||||
SIG: "POTA", SIGInfo: "US-0001", MySIG: "WWFF", MySIGInfo: "ONFF-0001",
|
||||
SIG: "POTA", SIGInfo: "US-0001", MySIG: "WWFF", MySIGInfo: "ONFF-0001",
|
||||
WWFFRef: "ONFF-0001", MyWWFFRef: "F-FFF-0001",
|
||||
Distance: &dist, RXPower: &rxp, AIndex: &a,
|
||||
SKCC: "12345S", FISTS: "999", TenTen: "55555",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-- The lookup cache gains the two fields the providers were already sending and
|
||||
-- nothing was reading: the operator's own site, and the postal code.
|
||||
--
|
||||
-- `web` matters beyond the cache: the qso table has had a `web` column since
|
||||
-- 0003 and no lookup ever filled it.
|
||||
ALTER TABLE callsign_cache ADD COLUMN web TEXT;
|
||||
ALTER TABLE callsign_cache ADD COLUMN zip TEXT;
|
||||
@@ -132,6 +132,18 @@ func (db *DB) Entities() []*Entity {
|
||||
func (db *DB) Lookup(callsign string) (Match, bool) {
|
||||
db.mu.RLock()
|
||||
defer db.mu.RUnlock()
|
||||
// Maritime and aeronautical mobile belong to NO entity, and that is not a
|
||||
// technicality — the station is at sea or in the air. RI1FJL/MM resolved to
|
||||
// Franz Josef Land while the expedition was still on its way there, telling
|
||||
// the operator they had worked an entity they had not.
|
||||
//
|
||||
// The previous choice was deliberate ("the log should still show the
|
||||
// operator's country") and it is wrong for exactly that reason: a home
|
||||
// country here is not extra information, it is a false claim about where the
|
||||
// contact happened.
|
||||
if IsMobileNoEntity(callsign) {
|
||||
return Match{}, false
|
||||
}
|
||||
call := normalizeCallsign(callsign)
|
||||
if call == "" {
|
||||
return Match{}, false
|
||||
@@ -364,3 +376,22 @@ func replaceAreaDigit(call string, d byte) string {
|
||||
}
|
||||
return call
|
||||
}
|
||||
|
||||
// IsMobileNoEntity reports a callsign DXCC assigns to no entity: a TRAILING
|
||||
// /MM (maritime mobile) or /AM (aeronautical mobile).
|
||||
//
|
||||
// Trailing only. A LEADING "MM" is the Scotland prefix and "AM" is Spain, so
|
||||
// MM0ABC and AM5X are ordinary calls — treating those as entity-less would be a
|
||||
// far larger error than the one this fixes.
|
||||
func IsMobileNoEntity(callsign string) bool {
|
||||
s := strings.ToUpper(strings.TrimSpace(callsign))
|
||||
i := strings.LastIndex(s, "/")
|
||||
if i < 0 {
|
||||
return false
|
||||
}
|
||||
switch s[i+1:] {
|
||||
case "MM", "AM":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
+338
-338
@@ -4,348 +4,348 @@ package dxcc
|
||||
// numbers. Generated by joining cty.dat to the authoritative ARRL/ADIF entity
|
||||
// list (k0swe/dxcc-json) by primary prefix + canonical name. 344 entities.
|
||||
var dxccByName = map[string]int{
|
||||
"afghanistan": 3,
|
||||
"agalega & st. brandon": 4,
|
||||
"aland islands": 5,
|
||||
"alaska": 6,
|
||||
"albania": 7,
|
||||
"algeria": 400,
|
||||
"american samoa": 9,
|
||||
"afghanistan": 3,
|
||||
"agalega & st. brandon": 4,
|
||||
"aland islands": 5,
|
||||
"alaska": 6,
|
||||
"albania": 7,
|
||||
"algeria": 400,
|
||||
"american samoa": 9,
|
||||
"amsterdam & st. paul is.": 10,
|
||||
"andaman & nicobar is.": 11,
|
||||
"andorra": 203,
|
||||
"angola": 401,
|
||||
"anguilla": 12,
|
||||
"annobon island": 195,
|
||||
"antarctica": 13,
|
||||
"antigua & barbuda": 94,
|
||||
"argentina": 100,
|
||||
"armenia": 14,
|
||||
"aruba": 91,
|
||||
"ascension island": 205,
|
||||
"asiatic russia": 15,
|
||||
"asiatic turkey": 390,
|
||||
"austral islands": 508,
|
||||
"australia": 150,
|
||||
"austria": 206,
|
||||
"aves island": 17,
|
||||
"azerbaijan": 18,
|
||||
"azores": 149,
|
||||
"bahamas": 60,
|
||||
"bahrain": 304,
|
||||
"baker & howland islands": 20,
|
||||
"balearic islands": 21,
|
||||
"banaba island": 490,
|
||||
"bangladesh": 305,
|
||||
"barbados": 62,
|
||||
"bear island": 259,
|
||||
"belarus": 27,
|
||||
"belgium": 209,
|
||||
"belize": 66,
|
||||
"benin": 416,
|
||||
"bermuda": 64,
|
||||
"bhutan": 306,
|
||||
"bolivia": 104,
|
||||
"bonaire": 520,
|
||||
"bosnia-herzegovina": 501,
|
||||
"botswana": 402,
|
||||
"bouvet": 24,
|
||||
"brazil": 108,
|
||||
"british virgin islands": 65,
|
||||
"brunei darussalam": 345,
|
||||
"bulgaria": 212,
|
||||
"burkina faso": 480,
|
||||
"burundi": 404,
|
||||
"cambodia": 312,
|
||||
"cameroon": 406,
|
||||
"canada": 1,
|
||||
"canary islands": 29,
|
||||
"cape verde": 409,
|
||||
"cayman islands": 69,
|
||||
"andaman & nicobar is.": 11,
|
||||
"andorra": 203,
|
||||
"angola": 401,
|
||||
"anguilla": 12,
|
||||
"annobon island": 195,
|
||||
"antarctica": 13,
|
||||
"antigua & barbuda": 94,
|
||||
"argentina": 100,
|
||||
"armenia": 14,
|
||||
"aruba": 91,
|
||||
"ascension island": 205,
|
||||
"asiatic russia": 15,
|
||||
"asiatic turkey": 390,
|
||||
"austral islands": 508,
|
||||
"australia": 150,
|
||||
"austria": 206,
|
||||
"aves island": 17,
|
||||
"azerbaijan": 18,
|
||||
"azores": 149,
|
||||
"bahamas": 60,
|
||||
"bahrain": 304,
|
||||
"baker & howland islands": 20,
|
||||
"balearic islands": 21,
|
||||
"banaba island": 490,
|
||||
"bangladesh": 305,
|
||||
"barbados": 62,
|
||||
"bear island": 259,
|
||||
"belarus": 27,
|
||||
"belgium": 209,
|
||||
"belize": 66,
|
||||
"benin": 416,
|
||||
"bermuda": 64,
|
||||
"bhutan": 306,
|
||||
"bolivia": 104,
|
||||
"bonaire": 520,
|
||||
"bosnia-herzegovina": 501,
|
||||
"botswana": 402,
|
||||
"bouvet": 24,
|
||||
"brazil": 108,
|
||||
"british virgin islands": 65,
|
||||
"brunei darussalam": 345,
|
||||
"bulgaria": 212,
|
||||
"burkina faso": 480,
|
||||
"burundi": 404,
|
||||
"cambodia": 312,
|
||||
"cameroon": 406,
|
||||
"canada": 1,
|
||||
"canary islands": 29,
|
||||
"cape verde": 409,
|
||||
"cayman islands": 69,
|
||||
"central african republic": 408,
|
||||
"central kiribati": 31,
|
||||
"ceuta & melilla": 32,
|
||||
"chad": 410,
|
||||
"chagos islands": 33,
|
||||
"chatham islands": 34,
|
||||
"chesterfield islands": 512,
|
||||
"chile": 112,
|
||||
"china": 318,
|
||||
"christmas island": 35,
|
||||
"clipperton island": 36,
|
||||
"cocos (keeling) islands": 38,
|
||||
"cocos island": 37,
|
||||
"colombia": 116,
|
||||
"comoros": 411,
|
||||
"conway reef": 489,
|
||||
"corsica": 214,
|
||||
"costa rica": 308,
|
||||
"cote d'ivoire": 428,
|
||||
"crete": 40,
|
||||
"croatia": 497,
|
||||
"crozet island": 41,
|
||||
"cuba": 70,
|
||||
"curacao": 517,
|
||||
"cyprus": 215,
|
||||
"czech republic": 503,
|
||||
"dem. rep. of the congo": 414,
|
||||
"denmark": 221,
|
||||
"desecheo island": 43,
|
||||
"djibouti": 382,
|
||||
"dodecanese": 45,
|
||||
"dominica": 95,
|
||||
"dominican republic": 72,
|
||||
"dpr of korea": 344,
|
||||
"ducie island": 513,
|
||||
"east malaysia": 46,
|
||||
"easter island": 47,
|
||||
"eastern kiribati": 48,
|
||||
"ecuador": 120,
|
||||
"egypt": 478,
|
||||
"el salvador": 74,
|
||||
"england": 223,
|
||||
"equatorial guinea": 49,
|
||||
"eritrea": 51,
|
||||
"estonia": 52,
|
||||
"ethiopia": 53,
|
||||
"european russia": 54,
|
||||
"european turkey": 390,
|
||||
"falkland islands": 141,
|
||||
"faroe islands": 222,
|
||||
"fed. rep. of germany": 230,
|
||||
"fernando de noronha": 56,
|
||||
"fiji": 176,
|
||||
"finland": 224,
|
||||
"france": 227,
|
||||
"franz josef land": 61,
|
||||
"french guiana": 63,
|
||||
"french polynesia": 175,
|
||||
"gabon": 420,
|
||||
"galapagos islands": 71,
|
||||
"georgia": 75,
|
||||
"ghana": 424,
|
||||
"gibraltar": 233,
|
||||
"glorioso islands": 99,
|
||||
"greece": 236,
|
||||
"greenland": 237,
|
||||
"grenada": 77,
|
||||
"guadeloupe": 79,
|
||||
"guam": 103,
|
||||
"guantanamo bay": 105,
|
||||
"guatemala": 76,
|
||||
"guernsey": 106,
|
||||
"guinea": 107,
|
||||
"guinea-bissau": 109,
|
||||
"guyana": 129,
|
||||
"haiti": 78,
|
||||
"hawaii": 110,
|
||||
"heard island": 111,
|
||||
"honduras": 80,
|
||||
"hong kong": 321,
|
||||
"hungary": 239,
|
||||
"iceland": 242,
|
||||
"india": 324,
|
||||
"indonesia": 327,
|
||||
"iran": 330,
|
||||
"iraq": 333,
|
||||
"ireland": 245,
|
||||
"isle of man": 114,
|
||||
"israel": 336,
|
||||
"italy": 248,
|
||||
"itu hq": 117,
|
||||
"jamaica": 82,
|
||||
"jan mayen": 118,
|
||||
"japan": 339,
|
||||
"jersey": 122,
|
||||
"johnston island": 123,
|
||||
"jordan": 342,
|
||||
"juan de nova, europa": 124,
|
||||
"juan fernandez islands": 125,
|
||||
"kaliningrad": 126,
|
||||
"kazakhstan": 130,
|
||||
"kenya": 430,
|
||||
"kerguelen islands": 131,
|
||||
"kermadec islands": 133,
|
||||
"kingdom of eswatini": 468,
|
||||
"kure island": 138,
|
||||
"kuwait": 348,
|
||||
"kyrgyzstan": 135,
|
||||
"lakshadweep islands": 142,
|
||||
"laos": 143,
|
||||
"latvia": 145,
|
||||
"lebanon": 354,
|
||||
"lesotho": 432,
|
||||
"liberia": 434,
|
||||
"libya": 436,
|
||||
"liechtenstein": 251,
|
||||
"lithuania": 146,
|
||||
"lord howe island": 147,
|
||||
"luxembourg": 254,
|
||||
"macao": 152,
|
||||
"macquarie island": 153,
|
||||
"madagascar": 438,
|
||||
"madeira islands": 256,
|
||||
"malawi": 440,
|
||||
"maldives": 159,
|
||||
"mali": 442,
|
||||
"malpelo island": 161,
|
||||
"malta": 257,
|
||||
"mariana islands": 166,
|
||||
"market reef": 167,
|
||||
"marquesas islands": 509,
|
||||
"marshall islands": 168,
|
||||
"martinique": 84,
|
||||
"mauritania": 444,
|
||||
"mauritius": 165,
|
||||
"mayotte": 169,
|
||||
"mellish reef": 171,
|
||||
"mexico": 50,
|
||||
"micronesia": 173,
|
||||
"midway island": 174,
|
||||
"minami torishima": 177,
|
||||
"moldova": 179,
|
||||
"monaco": 260,
|
||||
"mongolia": 363,
|
||||
"montenegro": 514,
|
||||
"montserrat": 96,
|
||||
"morocco": 446,
|
||||
"mount athos": 180,
|
||||
"mozambique": 181,
|
||||
"myanmar": 309,
|
||||
"n.z. subantarctic is.": 16,
|
||||
"namibia": 464,
|
||||
"nauru": 157,
|
||||
"navassa island": 182,
|
||||
"nepal": 369,
|
||||
"netherlands": 263,
|
||||
"new caledonia": 162,
|
||||
"new zealand": 170,
|
||||
"nicaragua": 86,
|
||||
"niger": 187,
|
||||
"nigeria": 450,
|
||||
"niue": 188,
|
||||
"norfolk island": 189,
|
||||
"north cook islands": 191,
|
||||
"north macedonia": 502,
|
||||
"northern ireland": 265,
|
||||
"norway": 266,
|
||||
"ogasawara": 192,
|
||||
"oman": 370,
|
||||
"pakistan": 372,
|
||||
"palau": 22,
|
||||
"palestine": 510,
|
||||
"central kiribati": 31,
|
||||
"ceuta & melilla": 32,
|
||||
"chad": 410,
|
||||
"chagos islands": 33,
|
||||
"chatham islands": 34,
|
||||
"chesterfield islands": 512,
|
||||
"chile": 112,
|
||||
"china": 318,
|
||||
"christmas island": 35,
|
||||
"clipperton island": 36,
|
||||
"cocos (keeling) islands": 38,
|
||||
"cocos island": 37,
|
||||
"colombia": 116,
|
||||
"comoros": 411,
|
||||
"conway reef": 489,
|
||||
"corsica": 214,
|
||||
"costa rica": 308,
|
||||
"cote d'ivoire": 428,
|
||||
"crete": 40,
|
||||
"croatia": 497,
|
||||
"crozet island": 41,
|
||||
"cuba": 70,
|
||||
"curacao": 517,
|
||||
"cyprus": 215,
|
||||
"czech republic": 503,
|
||||
"dem. rep. of the congo": 414,
|
||||
"denmark": 221,
|
||||
"desecheo island": 43,
|
||||
"djibouti": 382,
|
||||
"dodecanese": 45,
|
||||
"dominica": 95,
|
||||
"dominican republic": 72,
|
||||
"dpr of korea": 344,
|
||||
"ducie island": 513,
|
||||
"east malaysia": 46,
|
||||
"easter island": 47,
|
||||
"eastern kiribati": 48,
|
||||
"ecuador": 120,
|
||||
"egypt": 478,
|
||||
"el salvador": 74,
|
||||
"england": 223,
|
||||
"equatorial guinea": 49,
|
||||
"eritrea": 51,
|
||||
"estonia": 52,
|
||||
"ethiopia": 53,
|
||||
"european russia": 54,
|
||||
"european turkey": 390,
|
||||
"falkland islands": 141,
|
||||
"faroe islands": 222,
|
||||
"fed. rep. of germany": 230,
|
||||
"fernando de noronha": 56,
|
||||
"fiji": 176,
|
||||
"finland": 224,
|
||||
"france": 227,
|
||||
"franz josef land": 61,
|
||||
"french guiana": 63,
|
||||
"french polynesia": 175,
|
||||
"gabon": 420,
|
||||
"galapagos islands": 71,
|
||||
"georgia": 75,
|
||||
"ghana": 424,
|
||||
"gibraltar": 233,
|
||||
"glorioso islands": 99,
|
||||
"greece": 236,
|
||||
"greenland": 237,
|
||||
"grenada": 77,
|
||||
"guadeloupe": 79,
|
||||
"guam": 103,
|
||||
"guantanamo bay": 105,
|
||||
"guatemala": 76,
|
||||
"guernsey": 106,
|
||||
"guinea": 107,
|
||||
"guinea-bissau": 109,
|
||||
"guyana": 129,
|
||||
"haiti": 78,
|
||||
"hawaii": 110,
|
||||
"heard island": 111,
|
||||
"honduras": 80,
|
||||
"hong kong": 321,
|
||||
"hungary": 239,
|
||||
"iceland": 242,
|
||||
"india": 324,
|
||||
"indonesia": 327,
|
||||
"iran": 330,
|
||||
"iraq": 333,
|
||||
"ireland": 245,
|
||||
"isle of man": 114,
|
||||
"israel": 336,
|
||||
"italy": 248,
|
||||
"itu hq": 117,
|
||||
"jamaica": 82,
|
||||
"jan mayen": 118,
|
||||
"japan": 339,
|
||||
"jersey": 122,
|
||||
"johnston island": 123,
|
||||
"jordan": 342,
|
||||
"juan de nova, europa": 124,
|
||||
"juan fernandez islands": 125,
|
||||
"kaliningrad": 126,
|
||||
"kazakhstan": 130,
|
||||
"kenya": 430,
|
||||
"kerguelen islands": 131,
|
||||
"kermadec islands": 133,
|
||||
"kingdom of eswatini": 468,
|
||||
"kure island": 138,
|
||||
"kuwait": 348,
|
||||
"kyrgyzstan": 135,
|
||||
"lakshadweep islands": 142,
|
||||
"laos": 143,
|
||||
"latvia": 145,
|
||||
"lebanon": 354,
|
||||
"lesotho": 432,
|
||||
"liberia": 434,
|
||||
"libya": 436,
|
||||
"liechtenstein": 251,
|
||||
"lithuania": 146,
|
||||
"lord howe island": 147,
|
||||
"luxembourg": 254,
|
||||
"macao": 152,
|
||||
"macquarie island": 153,
|
||||
"madagascar": 438,
|
||||
"madeira islands": 256,
|
||||
"malawi": 440,
|
||||
"maldives": 159,
|
||||
"mali": 442,
|
||||
"malpelo island": 161,
|
||||
"malta": 257,
|
||||
"mariana islands": 166,
|
||||
"market reef": 167,
|
||||
"marquesas islands": 509,
|
||||
"marshall islands": 168,
|
||||
"martinique": 84,
|
||||
"mauritania": 444,
|
||||
"mauritius": 165,
|
||||
"mayotte": 169,
|
||||
"mellish reef": 171,
|
||||
"mexico": 50,
|
||||
"micronesia": 173,
|
||||
"midway island": 174,
|
||||
"minami torishima": 177,
|
||||
"moldova": 179,
|
||||
"monaco": 260,
|
||||
"mongolia": 363,
|
||||
"montenegro": 514,
|
||||
"montserrat": 96,
|
||||
"morocco": 446,
|
||||
"mount athos": 180,
|
||||
"mozambique": 181,
|
||||
"myanmar": 309,
|
||||
"n.z. subantarctic is.": 16,
|
||||
"namibia": 464,
|
||||
"nauru": 157,
|
||||
"navassa island": 182,
|
||||
"nepal": 369,
|
||||
"netherlands": 263,
|
||||
"new caledonia": 162,
|
||||
"new zealand": 170,
|
||||
"nicaragua": 86,
|
||||
"niger": 187,
|
||||
"nigeria": 450,
|
||||
"niue": 188,
|
||||
"norfolk island": 189,
|
||||
"north cook islands": 191,
|
||||
"north macedonia": 502,
|
||||
"northern ireland": 265,
|
||||
"norway": 266,
|
||||
"ogasawara": 192,
|
||||
"oman": 370,
|
||||
"pakistan": 372,
|
||||
"palau": 22,
|
||||
"palestine": 510,
|
||||
"palmyra & jarvis islands": 197,
|
||||
"panama": 88,
|
||||
"papua new guinea": 163,
|
||||
"paraguay": 132,
|
||||
"peru": 136,
|
||||
"peter 1 island": 199,
|
||||
"philippines": 375,
|
||||
"pitcairn island": 172,
|
||||
"poland": 269,
|
||||
"portugal": 272,
|
||||
"pr. edward & marion is.": 201,
|
||||
"pratas island": 505,
|
||||
"puerto rico": 202,
|
||||
"qatar": 376,
|
||||
"republic of korea": 137,
|
||||
"republic of kosovo": 522,
|
||||
"republic of south sudan": 521,
|
||||
"republic of the congo": 412,
|
||||
"reunion island": 453,
|
||||
"revillagigedo": 204,
|
||||
"rodriguez island": 207,
|
||||
"romania": 275,
|
||||
"rotuma island": 460,
|
||||
"rwanda": 454,
|
||||
"saba & st. eustatius": 519,
|
||||
"sable island": 211,
|
||||
"samoa": 190,
|
||||
"panama": 88,
|
||||
"papua new guinea": 163,
|
||||
"paraguay": 132,
|
||||
"peru": 136,
|
||||
"peter 1 island": 199,
|
||||
"philippines": 375,
|
||||
"pitcairn island": 172,
|
||||
"poland": 269,
|
||||
"portugal": 272,
|
||||
"pr. edward & marion is.": 201,
|
||||
"pratas island": 505,
|
||||
"puerto rico": 202,
|
||||
"qatar": 376,
|
||||
"republic of korea": 137,
|
||||
"republic of kosovo": 522,
|
||||
"republic of south sudan": 521,
|
||||
"republic of the congo": 412,
|
||||
"reunion island": 453,
|
||||
"revillagigedo": 204,
|
||||
"rodriguez island": 207,
|
||||
"romania": 275,
|
||||
"rotuma island": 460,
|
||||
"rwanda": 454,
|
||||
"saba & st. eustatius": 519,
|
||||
"sable island": 211,
|
||||
"samoa": 190,
|
||||
"san andres & providencia": 216,
|
||||
"san felix & san ambrosio": 217,
|
||||
"san marino": 278,
|
||||
"sao tome & principe": 219,
|
||||
"sardinia": 225,
|
||||
"saudi arabia": 378,
|
||||
"scarborough reef": 506,
|
||||
"scotland": 279,
|
||||
"senegal": 456,
|
||||
"serbia": 296,
|
||||
"seychelles": 379,
|
||||
"shetland islands": 279,
|
||||
"sierra leone": 458,
|
||||
"singapore": 381,
|
||||
"sint maarten": 518,
|
||||
"slovak republic": 504,
|
||||
"slovenia": 499,
|
||||
"solomon islands": 185,
|
||||
"somalia": 232,
|
||||
"south africa": 462,
|
||||
"south cook islands": 234,
|
||||
"south georgia island": 235,
|
||||
"south orkney islands": 238,
|
||||
"south sandwich islands": 240,
|
||||
"south shetland islands": 241,
|
||||
"sov mil order of malta": 246,
|
||||
"spain": 281,
|
||||
"spratly islands": 247,
|
||||
"sri lanka": 315,
|
||||
"st. barthelemy": 516,
|
||||
"st. helena": 250,
|
||||
"st. kitts & nevis": 249,
|
||||
"st. lucia": 97,
|
||||
"st. martin": 213,
|
||||
"st. paul island": 252,
|
||||
"st. peter & st. paul": 253,
|
||||
"st. pierre & miquelon": 277,
|
||||
"st. vincent": 98,
|
||||
"sudan": 466,
|
||||
"suriname": 140,
|
||||
"svalbard": 259,
|
||||
"swains island": 515,
|
||||
"sweden": 284,
|
||||
"switzerland": 287,
|
||||
"syria": 384,
|
||||
"taiwan": 386,
|
||||
"tajikistan": 262,
|
||||
"tanzania": 470,
|
||||
"temotu province": 507,
|
||||
"thailand": 387,
|
||||
"the gambia": 422,
|
||||
"timor - leste": 511,
|
||||
"togo": 483,
|
||||
"tokelau islands": 270,
|
||||
"tonga": 160,
|
||||
"trindade & martim vaz": 273,
|
||||
"trinidad & tobago": 90,
|
||||
"san marino": 278,
|
||||
"sao tome & principe": 219,
|
||||
"sardinia": 225,
|
||||
"saudi arabia": 378,
|
||||
"scarborough reef": 506,
|
||||
"scotland": 279,
|
||||
"senegal": 456,
|
||||
"serbia": 296,
|
||||
"seychelles": 379,
|
||||
"shetland islands": 279,
|
||||
"sierra leone": 458,
|
||||
"singapore": 381,
|
||||
"sint maarten": 518,
|
||||
"slovak republic": 504,
|
||||
"slovenia": 499,
|
||||
"solomon islands": 185,
|
||||
"somalia": 232,
|
||||
"south africa": 462,
|
||||
"south cook islands": 234,
|
||||
"south georgia island": 235,
|
||||
"south orkney islands": 238,
|
||||
"south sandwich islands": 240,
|
||||
"south shetland islands": 241,
|
||||
"sov mil order of malta": 246,
|
||||
"spain": 281,
|
||||
"spratly islands": 247,
|
||||
"sri lanka": 315,
|
||||
"st. barthelemy": 516,
|
||||
"st. helena": 250,
|
||||
"st. kitts & nevis": 249,
|
||||
"st. lucia": 97,
|
||||
"st. martin": 213,
|
||||
"st. paul island": 252,
|
||||
"st. peter & st. paul": 253,
|
||||
"st. pierre & miquelon": 277,
|
||||
"st. vincent": 98,
|
||||
"sudan": 466,
|
||||
"suriname": 140,
|
||||
"svalbard": 259,
|
||||
"swains island": 515,
|
||||
"sweden": 284,
|
||||
"switzerland": 287,
|
||||
"syria": 384,
|
||||
"taiwan": 386,
|
||||
"tajikistan": 262,
|
||||
"tanzania": 470,
|
||||
"temotu province": 507,
|
||||
"thailand": 387,
|
||||
"the gambia": 422,
|
||||
"timor - leste": 511,
|
||||
"togo": 483,
|
||||
"tokelau islands": 270,
|
||||
"tonga": 160,
|
||||
"trindade & martim vaz": 273,
|
||||
"trinidad & tobago": 90,
|
||||
"tristan da cunha & gough": 274,
|
||||
"tromelin island": 276,
|
||||
"tunisia": 474,
|
||||
"turkmenistan": 280,
|
||||
"turks & caicos islands": 89,
|
||||
"tuvalu": 282,
|
||||
"uganda": 286,
|
||||
"uk base areas on cyprus": 283,
|
||||
"ukraine": 288,
|
||||
"united arab emirates": 391,
|
||||
"united nations hq": 289,
|
||||
"united states": 291,
|
||||
"uruguay": 144,
|
||||
"us virgin islands": 285,
|
||||
"uzbekistan": 292,
|
||||
"vanuatu": 158,
|
||||
"vatican city": 295,
|
||||
"venezuela": 148,
|
||||
"vienna intl ctr": 206,
|
||||
"vietnam": 293,
|
||||
"wake island": 297,
|
||||
"wales": 294,
|
||||
"wallis & futuna islands": 298,
|
||||
"west malaysia": 299, // hand-fixed: generation joined it to 155 by mistake (9M2 = ADIF 299)
|
||||
"western kiribati": 301,
|
||||
"western sahara": 302,
|
||||
"willis island": 303,
|
||||
"yemen": 492,
|
||||
"zambia": 482,
|
||||
"zimbabwe": 452,
|
||||
"tromelin island": 276,
|
||||
"tunisia": 474,
|
||||
"turkmenistan": 280,
|
||||
"turks & caicos islands": 89,
|
||||
"tuvalu": 282,
|
||||
"uganda": 286,
|
||||
"uk base areas on cyprus": 283,
|
||||
"ukraine": 288,
|
||||
"united arab emirates": 391,
|
||||
"united nations hq": 289,
|
||||
"united states": 291,
|
||||
"uruguay": 144,
|
||||
"us virgin islands": 285,
|
||||
"uzbekistan": 292,
|
||||
"vanuatu": 158,
|
||||
"vatican city": 295,
|
||||
"venezuela": 148,
|
||||
"vienna intl ctr": 206,
|
||||
"vietnam": 293,
|
||||
"wake island": 297,
|
||||
"wales": 294,
|
||||
"wallis & futuna islands": 298,
|
||||
"west malaysia": 299, // hand-fixed: generation joined it to 155 by mistake (9M2 = ADIF 299)
|
||||
"western kiribati": 301,
|
||||
"western sahara": 302,
|
||||
"willis island": 303,
|
||||
"yemen": 492,
|
||||
"zambia": 482,
|
||||
"zimbabwe": 452,
|
||||
}
|
||||
|
||||
+21
-21
@@ -178,9 +178,9 @@ Sicily: 15: 28: EU: 37.50: -14.00: -1.0: *IT9:
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
cases := map[string]string{
|
||||
"IW9EZO": "Italy", // Sicily (*IT9) → Italy
|
||||
"IW9EZO": "Italy", // Sicily (*IT9) → Italy
|
||||
"IT9CLY": "Italy",
|
||||
"IG9A": "Italy", // African Italy (*IG9) → Italy
|
||||
"IG9A": "Italy", // African Italy (*IG9) → Italy
|
||||
"IK0ABC": "Italy",
|
||||
"IS0XYZ": "Sardinia", // real DXCC entity — must stay Sardinia
|
||||
"IM0ABC": "Sardinia",
|
||||
@@ -210,25 +210,25 @@ Sicily: 15: 28: EU: 37.50: -14.00: -1.0: *IT9:
|
||||
|
||||
func TestNormalize(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"F4BPO": "F4BPO",
|
||||
"f4bpo": "F4BPO",
|
||||
" F4BPO ": "F4BPO",
|
||||
"F4BPO/P": "F4BPO",
|
||||
"F4BPO/MM": "F4BPO", // maritime mobile → strip, keep home entity for the log
|
||||
"F4BPO/AM": "F4BPO", // aeronautical mobile → strip, keep home entity for the log
|
||||
"F4BPO/M": "F4BPO", // plain mobile keeps the home entity
|
||||
"F4BPO/5": "F5BPO", // "/5" re-homes to call area 5
|
||||
"HD5MW/8": "HD8MW", // "/8" → Galápagos call area (HD8)
|
||||
"7X2ARA/4": "7X4ARA", // "/4" changes the AREA digit (2→4), NOT the 7 of the 7X prefix (was wrongly 4X2ARA = Israel)
|
||||
"3A2ABC/6": "3A6ABC", // digit-first prefix 3A: area digit is the 2, not the 3
|
||||
"4X1AB/2": "4X2AB", // 4X (Israel) area 1→2, keep the leading 4
|
||||
"DL/F4BPO": "DL",
|
||||
"MM/KA9P": "MM", // leading MM = Scotland operating prefix
|
||||
"MM/LY3X/P": "MM",
|
||||
"F4BPO/W6": "W6",
|
||||
"VK9/F4BPO": "VK9",
|
||||
"4U1UN/B": "4U1UN", // trailing /B = beacon → strip, keep the base call
|
||||
"B/F4BPO": "B", // leading B = China operating prefix, NOT a beacon
|
||||
"F4BPO": "F4BPO",
|
||||
"f4bpo": "F4BPO",
|
||||
" F4BPO ": "F4BPO",
|
||||
"F4BPO/P": "F4BPO",
|
||||
"F4BPO/MM": "F4BPO", // maritime mobile → strip, keep home entity for the log
|
||||
"F4BPO/AM": "F4BPO", // aeronautical mobile → strip, keep home entity for the log
|
||||
"F4BPO/M": "F4BPO", // plain mobile keeps the home entity
|
||||
"F4BPO/5": "F5BPO", // "/5" re-homes to call area 5
|
||||
"HD5MW/8": "HD8MW", // "/8" → Galápagos call area (HD8)
|
||||
"7X2ARA/4": "7X4ARA", // "/4" changes the AREA digit (2→4), NOT the 7 of the 7X prefix (was wrongly 4X2ARA = Israel)
|
||||
"3A2ABC/6": "3A6ABC", // digit-first prefix 3A: area digit is the 2, not the 3
|
||||
"4X1AB/2": "4X2AB", // 4X (Israel) area 1→2, keep the leading 4
|
||||
"DL/F4BPO": "DL",
|
||||
"MM/KA9P": "MM", // leading MM = Scotland operating prefix
|
||||
"MM/LY3X/P": "MM",
|
||||
"F4BPO/W6": "W6",
|
||||
"VK9/F4BPO": "VK9",
|
||||
"4U1UN/B": "4U1UN", // trailing /B = beacon → strip, keep the base call
|
||||
"B/F4BPO": "B", // leading B = China operating prefix, NOT a beacon
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normalizeCallsign(in); got != want {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package dxcc
|
||||
|
||||
import "testing"
|
||||
|
||||
// /MM and /AM belong to NO DXCC entity: the station is at sea or in the air.
|
||||
//
|
||||
// This is not a technicality. RI1FJL/MM resolved to Franz Josef Land while the
|
||||
// expedition was still sailing there, which told the operator they had worked
|
||||
// an entity they had not.
|
||||
func TestMobileNoEntity(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
call string
|
||||
want bool
|
||||
}{
|
||||
{"RI1FJL/MM", true},
|
||||
{"YB1SCY/AM", true},
|
||||
{"ri1fjl/mm", true},
|
||||
{" RI1FJL/MM ", true},
|
||||
// A LEADING MM is Scotland and AM is Spain. Treating those as
|
||||
// entity-less would be a far larger error than the one this fixes.
|
||||
{"MM0ABC", false},
|
||||
{"AM5X", false},
|
||||
{"MM/F4BPO", false},
|
||||
{"AM/DL1ABC", false},
|
||||
// Other suffixes keep their entity — a portable station is still ashore.
|
||||
{"F4BPO/P", false},
|
||||
{"F4BPO/M", false},
|
||||
{"4U1UN/B", false},
|
||||
{"F4BPO", false},
|
||||
// Only the LAST element counts: RI1FJL/MM/P is still maritime, and a
|
||||
// call whose /MM is not final is not.
|
||||
{"RI1FJL/MM/P", false},
|
||||
{"", false},
|
||||
} {
|
||||
if got := IsMobileNoEntity(tc.call); got != tc.want {
|
||||
t.Errorf("IsMobileNoEntity(%q) = %v, want %v", tc.call, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
-1
@@ -4,6 +4,7 @@ package email
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/wneessen/go-mail"
|
||||
@@ -85,7 +86,32 @@ func SendFiles(cfg Config, to, subject, body string, attachPaths []string) error
|
||||
return fmt.Errorf("smtp client: %w", err)
|
||||
}
|
||||
if err := client.DialAndSend(m); err != nil {
|
||||
return fmt.Errorf("send: %w", err)
|
||||
return fmt.Errorf("send via %s:%d (%s, %s): %w",
|
||||
cfg.Host, cfg.Port, cfg.Encryption, describeSize(attachPaths), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// describeSize reports what was attached, in bytes.
|
||||
//
|
||||
// "An existing connection was forcibly closed" during DATA is the same message
|
||||
// whether the server refused the size, hit a quota, or simply dropped the
|
||||
// socket — and the first of those is the only one the operator can act on
|
||||
// directly. Naming the size rules it in or out without a second attempt.
|
||||
func describeSize(paths []string) string {
|
||||
var total int64
|
||||
n := 0
|
||||
for _, p := range paths {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if fi, err := os.Stat(p); err == nil {
|
||||
total += fi.Size()
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
return "no attachment"
|
||||
}
|
||||
return fmt.Sprintf("%d attachment(s), %d KB", n, (total+1023)/1024)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// "An existing connection was forcibly closed" during DATA reads the same
|
||||
// whether the server refused the SIZE, hit a quota, or just dropped the socket
|
||||
// — and only the first is something the operator can act on. Naming the size in
|
||||
// the error rules it in or out without a second attempt.
|
||||
func TestDescribeSize(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
a := filepath.Join(dir, "card.jpg")
|
||||
if err := os.WriteFile(a, make([]byte, 2048), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got := describeSize(nil); got != "no attachment" {
|
||||
t.Errorf("no attachment = %q", got)
|
||||
}
|
||||
if got := describeSize([]string{""}); got != "no attachment" {
|
||||
t.Errorf("blank path = %q, want it ignored", got)
|
||||
}
|
||||
if got := describeSize([]string{a}); got != "1 attachment(s), 2 KB" {
|
||||
t.Errorf("one file = %q", got)
|
||||
}
|
||||
// A path that does not exist must not be counted — the mail library skips it,
|
||||
// so reporting it would describe a message that was never sent.
|
||||
if got := describeSize([]string{a, filepath.Join(dir, "gone.jpg")}); got != "1 attachment(s), 2 KB" {
|
||||
t.Errorf("missing file counted: %q", got)
|
||||
}
|
||||
}
|
||||
+25
-17
@@ -65,23 +65,25 @@ const (
|
||||
// AutoUpload + UploadMode are common to all (timing is per-service, so the
|
||||
// user can run e.g. Club Log immediate and QRZ delayed).
|
||||
type ServiceConfig struct {
|
||||
APIKey string `json:"api_key"`
|
||||
URL string `json:"url"` // Cloudlog/Wavelog: base URL of the user's own instance
|
||||
StationID string `json:"station_id"` // Cloudlog/Wavelog: station profile (location) id
|
||||
Email string `json:"email"` // Club Log account email
|
||||
Username string `json:"username"` // LoTW website login (for confirmation download)
|
||||
Password string `json:"password"` // Club Log account / LoTW website password
|
||||
Callsign string `json:"callsign"` // Club Log / HRDLog logbook (owner) callsign
|
||||
Code string `json:"code"` // HRDLog: account upload code
|
||||
QTHNickname string `json:"qth_nickname"` // eQSL: QTH nickname (when the account has several)
|
||||
ForceStationCallsign string `json:"force_station_callsign"` // QRZ + LoTW: override STATION_CALLSIGN
|
||||
TQSLPath string `json:"tqsl_path"` // LoTW: path to tqsl.exe
|
||||
StationLocation string `json:"station_location"` // LoTW: TQSL Station Location name
|
||||
KeyPassword string `json:"key_password"` // LoTW: certificate private-key password (optional)
|
||||
UploadFlags []string `json:"upload_flags"` // LoTW: set of lotw_sent values that mean "ready to upload" — any of "N"/"R"
|
||||
WriteLog bool `json:"write_log"` // LoTW: pass -t to write a TQSL diagnostic log
|
||||
AutoUpload bool `json:"auto_upload"`
|
||||
UploadMode UploadMode `json:"upload_mode"`
|
||||
APIKey string `json:"api_key"`
|
||||
URL string `json:"url"` // Cloudlog/Wavelog: base URL of the user's own instance
|
||||
StationID string `json:"station_id"` // Cloudlog/Wavelog: station profile (location) id
|
||||
Email string `json:"email"` // Club Log account email
|
||||
Username string `json:"username"` // LoTW website login (for confirmation download)
|
||||
Password string `json:"password"` // Club Log account / LoTW website password
|
||||
Callsign string `json:"callsign"` // Club Log / HRDLog logbook (owner) callsign
|
||||
Code string `json:"code"` // HRDLog: account upload code
|
||||
QTHNickname string `json:"qth_nickname"` // eQSL: QTH nickname (when the account has several)
|
||||
ForceStationCallsign string `json:"force_station_callsign"` // QRZ + LoTW: override STATION_CALLSIGN
|
||||
TQSLPath string `json:"tqsl_path"` // LoTW: path to tqsl.exe
|
||||
StationLocation string `json:"station_location"` // LoTW: TQSL Station Location name
|
||||
KeyPassword string `json:"key_password"` // LoTW: certificate private-key password (optional)
|
||||
UploadFlags []string `json:"upload_flags"` // LoTW: set of lotw_sent values that mean "ready to upload" — any of "N"/"R"
|
||||
WriteLog bool `json:"write_log"` // LoTW: pass -t to write a TQSL diagnostic log
|
||||
AutoUpload bool `json:"auto_upload"`
|
||||
// OnAir: HRDLog only — publish the live frequency, mode and rig on the site.
|
||||
OnAir bool `json:"on_air"`
|
||||
UploadMode UploadMode `json:"upload_mode"`
|
||||
}
|
||||
|
||||
// normalised returns the config with whitespace trimmed and a valid upload
|
||||
@@ -142,4 +144,10 @@ type UploadResult struct {
|
||||
OK bool // the service accepted (or already had) the QSO
|
||||
LogID string // service-assigned record id, when provided
|
||||
Message string // human-readable detail (reason on failure)
|
||||
// Ignored is set when the service accepted the submission but left some or
|
||||
// all of the QSOs out of it. TQSL does this for contacts already uploaded
|
||||
// AND for contacts outside the certificate's date range, and reports both
|
||||
// with the same exit code — so the caller must show the message rather than
|
||||
// decide on its own that everything went through.
|
||||
Ignored bool
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -143,3 +144,59 @@ func TestHRDLog(ctx context.Context, client *http.Client, cfg ServiceConfig) (st
|
||||
}
|
||||
return fmt.Sprintf("Credentials accepted — %s", callsign), nil
|
||||
}
|
||||
|
||||
// hrdlogOnAirURL is HRDLog's live-status endpoint — what puts "F4BPO is on air
|
||||
// 14,074,000 FM IC-7300" on the site's front page.
|
||||
//
|
||||
// Endpoint and field names taken from HRDLog's own library (github.com/iw1qlh/
|
||||
// HRDLOG-net-library, HrdProtocol.SendOnAirAsync), not from guesswork: it posts
|
||||
// Frequency in Hz, Mode, Radio, Callsign, Code and App.
|
||||
//
|
||||
// HTTPS where that library uses plain HTTP. The upload code is a credential and
|
||||
// has no business crossing the network in clear; the sibling NewEntry endpoint
|
||||
// on the same host already serves TLS.
|
||||
const hrdlogOnAirURL = "https://robot.hrdlog.net/OnAir.aspx"
|
||||
|
||||
// SendHRDLogOnAir publishes the current frequency, mode and rig.
|
||||
//
|
||||
// Deliberately fire-and-forget in spirit: it is a status broadcast, so a
|
||||
// failure is logged and never surfaced as an error the operator must clear —
|
||||
// but the message is returned so the caller can log WHAT the site said rather
|
||||
// than only that something went wrong.
|
||||
func SendHRDLogOnAir(ctx context.Context, client *http.Client, callsign, code string, freqHz int64, mode, radio string) (string, error) {
|
||||
callsign = strings.ToUpper(strings.TrimSpace(callsign))
|
||||
code = strings.TrimSpace(code)
|
||||
if callsign == "" || code == "" {
|
||||
return "", fmt.Errorf("hrdlog: callsign and upload code required")
|
||||
}
|
||||
if freqHz <= 0 {
|
||||
return "", fmt.Errorf("hrdlog: no frequency to announce")
|
||||
}
|
||||
form := url.Values{}
|
||||
form.Set("Callsign", callsign)
|
||||
form.Set("Code", code)
|
||||
form.Set("App", hrdlogApp)
|
||||
form.Set("Frequency", strconv.FormatInt(freqHz, 10))
|
||||
form.Set("Mode", strings.TrimSpace(mode))
|
||||
form.Set("Radio", strings.TrimSpace(radio))
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, hrdlogOnAirURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("hrdlog on-air: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 15 * time.Second}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("hrdlog on-air: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 32*1024))
|
||||
msg := strings.TrimSpace(string(body))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return msg, fmt.Errorf("hrdlog on-air: http %d", resp.StatusCode)
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
+51
-4
@@ -178,8 +178,12 @@ func fileExists(p string) bool {
|
||||
//
|
||||
// tqsl -d -x -a all -l "<location>" -u [-p <keypass>] <file.adi>
|
||||
//
|
||||
// Exit codes (TQSL): 0 = uploaded; 8 = nothing new (all duplicates/out of
|
||||
// range); 9 = some uploaded, some skipped; anything else = failure.
|
||||
// Exit codes are TQSL's own (see the table in its cmdline help). 8 and 9 are
|
||||
// the ones that matter and both used to be read as plain success: 8 means NO
|
||||
// QSOs were processed and 9 means some were left out — in each case because
|
||||
// they were already uploaded OR outside the callsign certificate's date range.
|
||||
// Reporting either as success is how a contact came to be stamped "uploaded"
|
||||
// while LoTW had never seen it.
|
||||
func UploadLoTW(ctx context.Context, cfg ServiceConfig, tempDir, adifRecord string) (UploadResult, error) {
|
||||
tqsl := strings.TrimSpace(cfg.TQSLPath)
|
||||
loc := strings.TrimSpace(cfg.StationLocation)
|
||||
@@ -249,11 +253,31 @@ func UploadLoTW(ctx context.Context, cfg ServiceConfig, tempDir, adifRecord stri
|
||||
}
|
||||
}
|
||||
|
||||
// TQSL's exit codes, from its own cmdline documentation. Two of them used to
|
||||
// be read as plain success, and that is how contacts came to be stamped
|
||||
// "uploaded" while LoTW had never seen them:
|
||||
//
|
||||
// 8 NO QSOs were processed — already uploaded OR OUT OF DATE RANGE
|
||||
// 9 some processed, some ignored — same two reasons
|
||||
// 14 some already uploaded, the rest signed
|
||||
//
|
||||
// "Out of date range" is the one that bites: a contact older than the
|
||||
// callsign certificate's validity is silently left out, and reporting that as
|
||||
// success stamped it sent for ever. TQSL says which case it is in its output,
|
||||
// so the message is carried up rather than replaced with a guess.
|
||||
switch code {
|
||||
case 0, 9:
|
||||
case 0:
|
||||
return UploadResult{OK: true, Message: "uploaded to LoTW"}, nil
|
||||
case 9, 14:
|
||||
return UploadResult{OK: true, Ignored: true, Message: tqslDetail(msg,
|
||||
"uploaded — but TQSL left some contacts out (already uploaded, or outside the certificate's date range)")}, nil
|
||||
case 8:
|
||||
return UploadResult{OK: true, Message: "already uploaded (duplicate)"}, nil
|
||||
// Nothing reached LoTW. NOT stamped as sent: a duplicate left at "R" is
|
||||
// harmless and will be refused again, while a contact wrongly marked sent
|
||||
// is one the operator will never think to look at again.
|
||||
return UploadResult{OK: false, Ignored: true, Message: tqslDetail(msg,
|
||||
"TQSL uploaded nothing — every contact was already uploaded, or outside the certificate's date range")},
|
||||
fmt.Errorf("lotw: no QSOs processed")
|
||||
default:
|
||||
if msg == "" {
|
||||
msg = fmt.Sprintf("tqsl exit code %d", code)
|
||||
@@ -262,6 +286,29 @@ func UploadLoTW(ctx context.Context, cfg ServiceConfig, tempDir, adifRecord stri
|
||||
}
|
||||
}
|
||||
|
||||
// tqslDetail keeps the lines of TQSL's own output that say what happened to the
|
||||
// contacts, and appends the summary.
|
||||
//
|
||||
// TQSL is explicit — "414 QSO records were already uploaded", "N QSO records
|
||||
// are out of date range" — and that sentence is the whole answer to "why is my
|
||||
// contact not on LoTW". It used to be captured and thrown away.
|
||||
func tqslDetail(out, summary string) string {
|
||||
var keep []string
|
||||
for _, ln := range strings.Split(out, "\n") {
|
||||
ln = strings.TrimSpace(ln)
|
||||
l := strings.ToLower(ln)
|
||||
if strings.Contains(l, "qso") && (strings.Contains(l, "already uploaded") ||
|
||||
strings.Contains(l, "date range") || strings.Contains(l, "ignored") ||
|
||||
strings.Contains(l, "duplicate")) {
|
||||
keep = append(keep, ln)
|
||||
}
|
||||
}
|
||||
if len(keep) == 0 {
|
||||
return summary
|
||||
}
|
||||
return summary + " — " + strings.Join(keep, "; ")
|
||||
}
|
||||
|
||||
// TestLoTW validates the LoTW config: tqsl present and the chosen station
|
||||
// location exists in station_data.
|
||||
func TestLoTW(cfg ServiceConfig, stationDataPath string) (string, error) {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package extsvc
|
||||
|
||||
import "testing"
|
||||
|
||||
// TQSL's own exit codes, from its cmdline documentation:
|
||||
//
|
||||
// 0 success
|
||||
// 8 NO QSOs were processed — already uploaded OR out of date range
|
||||
// 9 some processed, some ignored — same two reasons
|
||||
// 14 some already uploaded, the rest signed
|
||||
//
|
||||
// 8 and 9 both used to be read as plain success, and that is how a contact came
|
||||
// to be stamped "uploaded to LoTW" while LoTW had never seen it. The one that
|
||||
// bites is "out of date range": a contact older than the callsign certificate's
|
||||
// validity is silently left out of the submission.
|
||||
func TestTQSLDetailKeepsTheReason(t *testing.T) {
|
||||
out := `13:22:01: Signing using Callsign G0ABC, DXCC Entity ENGLAND
|
||||
13:22:02: /tmp/x.adi: 20 QSO records are out of date range
|
||||
13:22:02: Final Status: No QSOs were processed (8)`
|
||||
got := tqslDetail(out, "summary")
|
||||
if got == "summary" {
|
||||
t.Fatal("threw away TQSL's explanation — that sentence is the whole answer to \"why is my contact not on LoTW\"")
|
||||
}
|
||||
if !contains(got, "out of date range") {
|
||||
t.Errorf("the reason was lost: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTQSLDetailFallsBackToTheSummary(t *testing.T) {
|
||||
if got := tqslDetail("nothing useful here", "summary"); got != "summary" {
|
||||
t.Errorf("got %q, want the plain summary when TQSL said nothing specific", got)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(h, n string) bool {
|
||||
return len(h) >= len(n) && (func() bool {
|
||||
for i := 0; i+len(n) <= len(h); i++ {
|
||||
if h[i:i+len(n)] == n {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})()
|
||||
}
|
||||
@@ -67,3 +67,54 @@ func DistanceBetweenGrids(a, b string) (km float64, ok bool) {
|
||||
}
|
||||
return HaversineKm(lat1, lon1, lat2, lon2), true
|
||||
}
|
||||
|
||||
// LatLonToGrid returns the 4-character Maidenhead square for a position.
|
||||
func LatLonToGrid(lat, lon float64) string {
|
||||
lon = math.Mod(lon+180, 360)
|
||||
if lon < 0 {
|
||||
lon += 360
|
||||
}
|
||||
lat = lat + 90
|
||||
if lat < 0 {
|
||||
lat = 0
|
||||
} else if lat > 180 {
|
||||
lat = 180
|
||||
}
|
||||
return string([]byte{
|
||||
byte('A' + int(lon/20)),
|
||||
byte('A' + int(lat/10)),
|
||||
byte('0' + int(math.Mod(lon, 20)/2)),
|
||||
byte('0' + int(math.Mod(lat, 10)/1)),
|
||||
})
|
||||
}
|
||||
|
||||
// NeighbourGrids returns the square holding (lat, lon) and the ring of squares
|
||||
// around it — 9 squares for ring 1, 25 for ring 2.
|
||||
//
|
||||
// Used to filter the PSK Reporter feed at the BROKER rather than in OpsLog. A
|
||||
// square is about 111 km tall and 150 km wide at mid latitudes, so one ring is
|
||||
// roughly the 300 km "around here" the feed already meant, and the traffic that
|
||||
// used to be received and discarded is never sent.
|
||||
func NeighbourGrids(lat, lon float64, ring int) []string {
|
||||
if ring < 0 {
|
||||
ring = 0
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
out := []string{}
|
||||
for dLat := -ring; dLat <= ring; dLat++ {
|
||||
for dLon := -ring; dLon <= ring; dLon++ {
|
||||
// One square step: 1° of latitude, 2° of longitude.
|
||||
la := lat + float64(dLat)
|
||||
lo := lon + float64(dLon)*2
|
||||
if la > 90 || la < -90 {
|
||||
continue // past a pole there is no square, not a wrapped one
|
||||
}
|
||||
g := LatLonToGrid(la, lo)
|
||||
if !seen[g] {
|
||||
seen[g] = true
|
||||
out = append(out, g)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package geo
|
||||
|
||||
import "testing"
|
||||
|
||||
// The squares the PSK Reporter feed is filtered on. A wrong ring means either
|
||||
// receiving the world again or hearing nothing.
|
||||
func TestNeighbourGrids(t *testing.T) {
|
||||
// JN36 is around 46.5N 5.5E.
|
||||
lat, lon, ok := GridToLatLon("JN36")
|
||||
if !ok {
|
||||
t.Fatal("JN36 did not resolve")
|
||||
}
|
||||
if got := LatLonToGrid(lat, lon); got != "JN36" {
|
||||
t.Fatalf("round trip gave %q, want JN36", got)
|
||||
}
|
||||
|
||||
ring := NeighbourGrids(lat, lon, 1)
|
||||
if len(ring) != 9 {
|
||||
t.Errorf("ring 1 has %d squares, want 9: %v", len(ring), ring)
|
||||
}
|
||||
found := false
|
||||
for _, g := range ring {
|
||||
if g == "JN36" {
|
||||
found = true
|
||||
}
|
||||
if len(g) != 4 {
|
||||
t.Errorf("not a 4-character square: %q", g)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("the operator's own square is missing from %v", ring)
|
||||
}
|
||||
if n := len(NeighbourGrids(lat, lon, 0)); n != 1 {
|
||||
t.Errorf("ring 0 has %d squares, want just the operator's", n)
|
||||
}
|
||||
if n := len(NeighbourGrids(lat, lon, 2)); n != 25 {
|
||||
t.Errorf("ring 2 has %d squares, want 25", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Near a pole a step north has nowhere to go; it must be dropped, not wrapped
|
||||
// onto a square on the far side of the world.
|
||||
func TestNeighbourGridsNearThePole(t *testing.T) {
|
||||
for _, g := range NeighbourGrids(89.5, 25, 1) {
|
||||
if len(g) != 4 {
|
||||
t.Errorf("bad square near the pole: %q", g)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
// Package gridcache is the long-term callsign→grid store behind grid chasing.
|
||||
//
|
||||
// Its own SQLite file, not a table in the settings database: that one sits
|
||||
// wherever the operator put it, often a synchronised folder, and this rewrites
|
||||
// itself every minute. Deleting the file costs a few days of listening.
|
||||
//
|
||||
// A callsign has one grid and the newest report wins — a stale locator reads as
|
||||
// a square already worked, which is worse than none.
|
||||
package gridcache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// Retention bounds a store that would otherwise only ever grow. Two years is
|
||||
// chosen to be far longer than any propagation interest and short enough that a
|
||||
// reassigned callsign eventually stops carrying its previous holder's square —
|
||||
// the one way this cache can be actively wrong rather than merely empty.
|
||||
const Retention = 2 * 365 * 24 * time.Hour
|
||||
|
||||
// FlushEvery is the batch interval. Long enough that a burst of reports costs
|
||||
// one transaction, short enough that a crash loses a minute of learning.
|
||||
const FlushEvery = 60 * time.Second
|
||||
|
||||
// Sources a locator can come from.
|
||||
const (
|
||||
SourceDecode = "decode" // a CQ this station's own receiver decoded
|
||||
SourceMQTT = "mqtt" // a PSK Reporter report
|
||||
)
|
||||
|
||||
// Entry is one locator and where it came from.
|
||||
type Entry struct {
|
||||
Grid string
|
||||
Source string
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
|
||||
mu sync.Mutex
|
||||
dirty map[string]Entry // call → what to write
|
||||
|
||||
stop chan struct{}
|
||||
stopOnce sync.Once
|
||||
wg sync.WaitGroup
|
||||
|
||||
logf func(string, ...any)
|
||||
}
|
||||
|
||||
// Open creates or opens the store at path and prunes what has aged out.
|
||||
func Open(path string, logf func(string, ...any)) (*Store, error) {
|
||||
if logf == nil {
|
||||
logf = func(string, ...any) {}
|
||||
}
|
||||
// WAL so a flush never blocks a read, and a busy timeout because the flush
|
||||
// goroutine and the startup load can overlap on a slow disk.
|
||||
db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gridcache: open %s: %w", path, err)
|
||||
}
|
||||
if _, err := db.Exec("CREATE TABLE IF NOT EXISTS grids (" +
|
||||
"call TEXT PRIMARY KEY, grid TEXT NOT NULL, updated_at INTEGER NOT NULL, " +
|
||||
"source TEXT NOT NULL DEFAULT '')"); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("gridcache: schema: %w", err)
|
||||
}
|
||||
// A file written before the column existed keeps its rows; this fails
|
||||
// harmlessly when the column is already there.
|
||||
db.Exec("ALTER TABLE grids ADD COLUMN source TEXT NOT NULL DEFAULT ''")
|
||||
s := &Store{db: db, dirty: map[string]Entry{}, stop: make(chan struct{}), logf: logf}
|
||||
if n, err := s.prune(); err != nil {
|
||||
s.logf("gridcache: prune failed: %v", err)
|
||||
} else if n > 0 {
|
||||
s.logf("gridcache: pruned %d locators not heard in %d days", n, int(Retention.Hours()/24))
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) prune() (int64, error) {
|
||||
cut := time.Now().Add(-Retention).Unix()
|
||||
res, err := s.db.Exec(`DELETE FROM grids WHERE updated_at < ?`, cut)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
// LoadAll returns every stored locator, for seeding the in-memory map at
|
||||
// startup. One query and one map build — the point of the whole package is that
|
||||
// nothing afterwards has to ask the database anything.
|
||||
func (s *Store) LoadAll() (map[string]string, error) {
|
||||
rows, err := s.db.Query(`SELECT call, grid FROM grids`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gridcache: load: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[string]string, 4096)
|
||||
for rows.Next() {
|
||||
var call, grid string
|
||||
if err := rows.Scan(&call, &grid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[call] = grid
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Put queues a locator for writing. Callers pass only what CHANGED — an
|
||||
// unchanged report is the common case by a wide margin and must not reach here,
|
||||
// or the batch would carry the whole feed instead of the news in it.
|
||||
func (s *Store) Put(call, grid, source string) {
|
||||
call = strings.ToUpper(strings.TrimSpace(call))
|
||||
grid = strings.TrimSpace(grid)
|
||||
if call == "" || grid == "" {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.dirty[call] = Entry{Grid: grid, Source: source}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Start runs the flush loop until Close.
|
||||
func (s *Store) Start(ctx context.Context) {
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
t := time.NewTicker(FlushEvery)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-s.stop:
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
if err := s.Flush(); err != nil {
|
||||
s.logf("gridcache: flush failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Flush writes the pending batch in one transaction. Safe to call with nothing
|
||||
// pending, which is most of the time on a quiet band.
|
||||
func (s *Store) Flush() error {
|
||||
s.mu.Lock()
|
||||
if len(s.dirty) == 0 {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
batch := s.dirty
|
||||
s.dirty = map[string]Entry{}
|
||||
s.mu.Unlock()
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
s.requeue(batch)
|
||||
return err
|
||||
}
|
||||
st, err := tx.Prepare(`INSERT INTO grids (call, grid, updated_at, source) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(call) DO UPDATE SET grid = excluded.grid, updated_at = excluded.updated_at,
|
||||
source = excluded.source`)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
s.requeue(batch)
|
||||
return err
|
||||
}
|
||||
defer st.Close()
|
||||
now := time.Now().Unix()
|
||||
for call, e := range batch {
|
||||
if _, err := st.Exec(call, e.Grid, now, e.Source); err != nil {
|
||||
tx.Rollback()
|
||||
s.requeue(batch)
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
s.requeue(batch)
|
||||
return err
|
||||
}
|
||||
// Logged because a batch that never reaches the disk looks exactly like one
|
||||
// that does: the locators are in memory either way until the next restart,
|
||||
// which is the one moment the difference shows.
|
||||
s.logf("gridcache: wrote %d locators", len(batch))
|
||||
return nil
|
||||
}
|
||||
|
||||
// requeue puts a failed batch back, without overwriting anything learnt while it
|
||||
// was in flight — the newer value is the right one.
|
||||
func (s *Store) requeue(batch map[string]Entry) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for call, e := range batch {
|
||||
if _, newer := s.dirty[call]; !newer {
|
||||
s.dirty[call] = e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pending reports how many locators are waiting to be written.
|
||||
func (s *Store) Pending() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return len(s.dirty)
|
||||
}
|
||||
|
||||
// Close stops the loop and writes whatever is pending. A restart is the moment
|
||||
// the cache is most valuable, so losing the last minute of learning to a clean
|
||||
// shutdown would be a poor trade.
|
||||
func (s *Store) Close() error {
|
||||
s.stopOnce.Do(func() { close(s.stop) })
|
||||
s.wg.Wait()
|
||||
err := s.Flush()
|
||||
if cerr := s.db.Close(); err == nil {
|
||||
err = cerr
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package gridcache
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func open(t *testing.T) (*Store, string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "grids.db")
|
||||
s, err := Open(path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
return s, path
|
||||
}
|
||||
|
||||
// The whole reason the store exists: what was learnt is still there after a
|
||||
// restart, so the cluster's locator column is full in the first second instead
|
||||
// of after an hour of listening.
|
||||
func TestSurvivesRestart(t *testing.T) {
|
||||
s, path := open(t)
|
||||
s.Put("F4BPO", "JN36", SourceDecode)
|
||||
s.Put("OH5CX", "KP30", SourceDecode)
|
||||
if err := s.Flush(); err != nil {
|
||||
t.Fatalf("flush: %v", err)
|
||||
}
|
||||
if err := s.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
|
||||
again, err := Open(path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
defer again.Close()
|
||||
got, err := again.LoadAll()
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if got["F4BPO"] != "JN36" || got["OH5CX"] != "KP30" {
|
||||
t.Errorf("locators lost across a restart: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A callsign has ONE grid and the newest report wins. Operators move, go
|
||||
// portable, go on expedition — and a stale locator is worse than none for grid
|
||||
// chasing, because it reads as a square already worked.
|
||||
func TestNewestReportWins(t *testing.T) {
|
||||
s, _ := open(t)
|
||||
defer s.Close()
|
||||
|
||||
s.Put("F4BPO", "JN36", SourceDecode)
|
||||
if err := s.Flush(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.Put("F4BPO", "KP30", SourceDecode) // moved
|
||||
if err := s.Flush(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := s.LoadAll()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got["F4BPO"] != "KP30" {
|
||||
t.Errorf("grid = %q, want the newer KP30", got["F4BPO"])
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Errorf("a callsign must hold one row, got %d: %v", len(got), got)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing reaches the disk until a flush, and a flush with nothing pending is
|
||||
// not an error — that is most minutes on a quiet band.
|
||||
func TestBatching(t *testing.T) {
|
||||
s, _ := open(t)
|
||||
defer s.Close()
|
||||
|
||||
for _, c := range []string{"A1AA", "B2BB", "C3CC"} {
|
||||
s.Put(c, "JN36", SourceDecode)
|
||||
}
|
||||
if n := s.Pending(); n != 3 {
|
||||
t.Errorf("pending = %d, want 3 queued and unwritten", n)
|
||||
}
|
||||
if got, _ := s.LoadAll(); len(got) != 0 {
|
||||
t.Errorf("wrote before the flush: %v", got)
|
||||
}
|
||||
if err := s.Flush(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := s.Pending(); n != 0 {
|
||||
t.Errorf("pending = %d after a flush, want 0", n)
|
||||
}
|
||||
if got, _ := s.LoadAll(); len(got) != 3 {
|
||||
t.Errorf("flush wrote %d rows, want 3", len(got))
|
||||
}
|
||||
if err := s.Flush(); err != nil {
|
||||
t.Errorf("empty flush must be a no-op, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Age is what bounds a store that would otherwise only grow. A callsign not
|
||||
// heard in two years is likely reassigned, and carrying its previous holder's
|
||||
// square is the one way this cache can be actively wrong rather than empty.
|
||||
func TestPruneOnOpen(t *testing.T) {
|
||||
s, path := open(t)
|
||||
s.Put("FRESH", "JN36", SourceDecode)
|
||||
s.Put("STALE", "IO91", SourceDecode)
|
||||
if err := s.Flush(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Backdate one row past the retention window.
|
||||
old := time.Now().Add(-Retention - 24*time.Hour).Unix()
|
||||
if _, err := s.db.Exec(`UPDATE grids SET updated_at = ? WHERE call = 'STALE'`, old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.Close()
|
||||
|
||||
again, err := Open(path, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer again.Close()
|
||||
got, _ := again.LoadAll()
|
||||
if _, ok := got["STALE"]; ok {
|
||||
t.Error("an entry past the retention window survived — the store is unbounded")
|
||||
}
|
||||
if got["FRESH"] != "JN36" {
|
||||
t.Error("pruning took a live entry with it")
|
||||
}
|
||||
}
|
||||
|
||||
// Close has to write what the last minute learnt: a restart is exactly when the
|
||||
// cache is worth the most, so losing it to a clean shutdown would be a poor
|
||||
// trade.
|
||||
func TestCloseFlushes(t *testing.T) {
|
||||
s, path := open(t)
|
||||
s.Put("LATE", "JN36", SourceDecode)
|
||||
if err := s.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
again, err := Open(path, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer again.Close()
|
||||
got, _ := again.LoadAll()
|
||||
if got["LATE"] != "JN36" {
|
||||
t.Error("what was pending at shutdown was dropped")
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,13 @@ func (h *HamQTH) fetch(ctx context.Context, sessionID, callsign string) (Result,
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
return parseHamQTHSearch(body)
|
||||
}
|
||||
|
||||
// parseHamQTHSearch turns one answer into a Result. Split out from the request
|
||||
// so the field mapping can be checked against a captured payload — which is how
|
||||
// the missing full name and picture were found.
|
||||
func parseHamQTHSearch(body []byte) (Result, error) {
|
||||
var resp hamqthRoot
|
||||
if err := xml.Unmarshal(body, &resp); err != nil {
|
||||
return Result{}, fmt.Errorf("hamqth: parse callsign: %w", err)
|
||||
@@ -112,11 +119,20 @@ func (h *HamQTH) fetch(ctx context.Context, sessionID, callsign string) (Result,
|
||||
if s.Callsign == "" {
|
||||
return Result{}, ErrNotFound
|
||||
}
|
||||
// <nick> is the name the operator goes BY on the air, which is what belongs
|
||||
// in a log — "Igor", not "Igor Vladimirovich Getman". adr_name is the postal
|
||||
// name and is used only when there is nothing else, so a record carrying
|
||||
// neither nick nor name no longer resolves to blank.
|
||||
name := strings.TrimSpace(s.Nick + " " + s.LastName)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(s.AdrName)
|
||||
}
|
||||
r := Result{
|
||||
Callsign: strings.ToUpper(s.Callsign),
|
||||
Name: strings.TrimSpace(s.Nick + " " + s.LastName),
|
||||
Name: name,
|
||||
QTH: firstNonEmpty(s.QTH, s.AdrCity),
|
||||
Address: s.AdrStreet1,
|
||||
Zip: s.AdrZip,
|
||||
State: strings.ToUpper(s.USState),
|
||||
County: s.USCounty,
|
||||
Country: firstNonEmpty(s.AdrCountry, s.Country),
|
||||
@@ -124,6 +140,8 @@ func (h *HamQTH) fetch(ctx context.Context, sessionID, callsign string) (Result,
|
||||
Continent: strings.ToUpper(s.Continent),
|
||||
Email: s.Email,
|
||||
QSLVia: s.QSLVia,
|
||||
Web: s.Web,
|
||||
ImageURL: s.Picture,
|
||||
}
|
||||
r.Lat, _ = strconv.ParseFloat(s.Latitude, 64)
|
||||
r.Lon, _ = strconv.ParseFloat(s.Longitude, 64)
|
||||
@@ -182,4 +200,14 @@ type hamqthSearch struct {
|
||||
Continent string `xml:"continent"`
|
||||
Email string `xml:"email"`
|
||||
QSLVia string `xml:"qsl_via"`
|
||||
// AdrName is the full postal name. Many records carry it and no <name> at
|
||||
// all, so building the name from <nick> + <name> silently kept the first
|
||||
// name and dropped the rest.
|
||||
AdrName string `xml:"adr_name"`
|
||||
AdrZip string `xml:"adr_zip"`
|
||||
// Picture is HamQTH's profile photo. Result.ImageURL was documented as "QRZ
|
||||
// only" because this element was never read — QRZ calls the same thing
|
||||
// <image>.
|
||||
Picture string `xml:"picture"`
|
||||
Web string `xml:"web"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package lookup
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A real HamQTH answer, captured from the live API.
|
||||
//
|
||||
// Two things it settles. The email IS returned and must reach the QSO. And the
|
||||
// element names are not the ones a reading of the documentation suggests:
|
||||
// there is no <name> here at all, the full name is <adr_name>, and the picture
|
||||
// is <picture> where QRZ calls it <image>. The stray <div> is an advert the
|
||||
// server injects into its own XML; the parser has to shrug it off.
|
||||
const hamqthEU1EU = `<?xml version="1.0"?>
|
||||
<HamQTH xmlns="https://www.hamqth.com" version="2.8">
|
||||
<div id="in-page-channel-node-id" data-channel-name="in_page_channel_Sux7_K"/>
|
||||
<search>
|
||||
<callsign>eu1eu</callsign>
|
||||
<nick>Igor</nick>
|
||||
<qth>Minsk-5</qth>
|
||||
<country>Belarus</country>
|
||||
<adif>27</adif>
|
||||
<itu>29</itu>
|
||||
<cq>16</cq>
|
||||
<grid>KO33SV</grid>
|
||||
<adr_name>Igor Vladimirovich Getman</adr_name>
|
||||
<adr_street1>A/ya 143</adr_street1>
|
||||
<adr_city>Minsk-5</adr_city>
|
||||
<adr_zip>220005</adr_zip>
|
||||
<adr_country>Belarus</adr_country>
|
||||
<adr_adif>27</adr_adif>
|
||||
<district>WAARB-LE</district>
|
||||
<lotw>?</lotw>
|
||||
<qsldirect>?</qsldirect>
|
||||
<qsl>?</qsl>
|
||||
<eqsl>Y</eqsl>
|
||||
<email>[email protected]</email>
|
||||
<latitude>53.88999938964844</latitude>
|
||||
<longitude>27.59000015258789</longitude>
|
||||
<continent>EU</continent>
|
||||
<utc_offset>-2</utc_offset>
|
||||
<picture>https://www.hamqth.com/images/default/ts-930_qith_old_radios.jpg</picture>
|
||||
</search>
|
||||
</HamQTH>`
|
||||
|
||||
func TestHamQTHParsesEveryUsefulField(t *testing.T) {
|
||||
var root hamqthRoot
|
||||
if err := xml.Unmarshal([]byte(hamqthEU1EU), &root); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
s := root.Search
|
||||
|
||||
for _, c := range []struct{ name, got, want string }{
|
||||
{"callsign", s.Callsign, "eu1eu"},
|
||||
{"email", s.Email, "[email protected]"},
|
||||
{"grid", s.Grid, "KO33SV"},
|
||||
{"qth", s.QTH, "Minsk-5"},
|
||||
{"street", s.AdrStreet1, "A/ya 143"},
|
||||
{"city", s.AdrCity, "Minsk-5"},
|
||||
{"country", s.AdrCountry, "Belarus"},
|
||||
{"continent", s.Continent, "EU"},
|
||||
{"dxcc", s.DXCC, "27"},
|
||||
{"cq", s.CQ, "16"},
|
||||
{"itu", s.ITU, "29"},
|
||||
} {
|
||||
if c.got != c.want {
|
||||
t.Errorf("%s = %q, want %q", c.name, c.got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The fields the parser was blind to. Each is data OpsLog has somewhere to put.
|
||||
func TestHamQTHParsesTheFieldsThatWereMissing(t *testing.T) {
|
||||
var root hamqthRoot
|
||||
if err := xml.Unmarshal([]byte(hamqthEU1EU), &root); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
s := root.Search
|
||||
|
||||
// The postal name, the fallback when a record carries no nick and no name.
|
||||
if s.AdrName != "Igor Vladimirovich Getman" {
|
||||
t.Errorf("adr_name = %q", s.AdrName)
|
||||
}
|
||||
if s.LastName != "" {
|
||||
t.Errorf("this record has no <name>; got %q", s.LastName)
|
||||
}
|
||||
// The profile picture. Result.ImageURL existed and said "QRZ only" — HamQTH
|
||||
// sends one too, under a different element name.
|
||||
if s.Picture == "" {
|
||||
t.Error("picture not parsed")
|
||||
}
|
||||
if s.AdrZip != "220005" {
|
||||
t.Errorf("adr_zip = %q", s.AdrZip)
|
||||
}
|
||||
}
|
||||
|
||||
// End to end: what the provider hands back must carry the email, the full name
|
||||
// and the picture.
|
||||
func TestHamQTHResultCarriesTheLot(t *testing.T) {
|
||||
r, err := parseHamQTHSearch([]byte(hamqthEU1EU))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if r.Email != "[email protected]" {
|
||||
t.Errorf("Email = %q — this is what was reported missing", r.Email)
|
||||
}
|
||||
// The nick, not the postal name: a log wants the name the operator goes by
|
||||
// on the air. adr_name is only the fallback when there is nothing else.
|
||||
if r.Name != "Igor" {
|
||||
t.Errorf("Name = %q, want the on-air nick", r.Name)
|
||||
}
|
||||
if r.ImageURL == "" {
|
||||
t.Error("ImageURL empty — HamQTH sent a picture")
|
||||
}
|
||||
if r.Grid != "KO33SV" {
|
||||
t.Errorf("Grid = %q", r.Grid)
|
||||
}
|
||||
if r.Lat < 53.8 || r.Lat > 53.9 {
|
||||
t.Errorf("Lat = %v", r.Lat)
|
||||
}
|
||||
if r.DXCC != 27 || r.CQZ != 16 || r.ITUZ != 29 {
|
||||
t.Errorf("dxcc/cq/itu = %d/%d/%d", r.DXCC, r.CQZ, r.ITUZ)
|
||||
}
|
||||
if r.Address != "A/ya 143" {
|
||||
t.Errorf("Address = %q", r.Address)
|
||||
}
|
||||
}
|
||||
+32
-23
@@ -21,23 +21,29 @@ var ErrNotFound = errors.New("callsign not found")
|
||||
|
||||
// Result is the normalized lookup output regardless of provider.
|
||||
type Result struct {
|
||||
Callsign string `json:"callsign"`
|
||||
Name string `json:"name,omitempty"`
|
||||
QTH string `json:"qth,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
County string `json:"cnty,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
Grid string `json:"grid,omitempty"`
|
||||
Lat float64 `json:"lat,omitempty"`
|
||||
Lon float64 `json:"lon,omitempty"`
|
||||
DXCC int `json:"dxcc,omitempty"`
|
||||
CQZ int `json:"cqz,omitempty"`
|
||||
ITUZ int `json:"ituz,omitempty"`
|
||||
Continent string `json:"cont,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
QSLVia string `json:"qsl_via,omitempty"`
|
||||
ImageURL string `json:"image_url,omitempty"` // profile picture URL (QRZ only for now)
|
||||
Callsign string `json:"callsign"`
|
||||
Name string `json:"name,omitempty"`
|
||||
QTH string `json:"qth,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
County string `json:"cnty,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
Grid string `json:"grid,omitempty"`
|
||||
Lat float64 `json:"lat,omitempty"`
|
||||
Lon float64 `json:"lon,omitempty"`
|
||||
DXCC int `json:"dxcc,omitempty"`
|
||||
CQZ int `json:"cqz,omitempty"`
|
||||
ITUZ int `json:"ituz,omitempty"`
|
||||
Continent string `json:"cont,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
QSLVia string `json:"qsl_via,omitempty"`
|
||||
// Web is the operator's own site. The QSO table has had a `web` column all
|
||||
// along and nothing ever filled it, because no provider mapping read the
|
||||
// field.
|
||||
Web string `json:"web,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
|
||||
Source string `json:"source"` // "qrz", "hamqth", or "cache"
|
||||
FetchedAt time.Time `json:"fetched_at"`
|
||||
}
|
||||
@@ -446,12 +452,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,
|
||||
source, fetched_at
|
||||
web, zip, 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
|
||||
src string
|
||||
dxcc, cqz, ituz sql.NullInt64
|
||||
lat, lon sql.NullFloat64
|
||||
@@ -459,7 +466,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,
|
||||
&dxcc, &cqz, &ituz, &cont, &email, &qslVia, &image, &web, &zip,
|
||||
&src, &fetched); err != nil {
|
||||
return Result{}, false
|
||||
}
|
||||
@@ -481,6 +488,8 @@ func (c *Cache) Get(ctx context.Context, callsign string) (Result, bool) {
|
||||
r.Lon = lon.Float64
|
||||
r.Continent = cont.String
|
||||
r.Email = email.String
|
||||
r.Web = web.String
|
||||
r.Zip = zip.String
|
||||
r.QSLVia = qslVia.String
|
||||
r.ImageURL = image.String
|
||||
r.DXCC = int(dxcc.Int64)
|
||||
@@ -497,7 +506,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",
|
||||
"dxcc", "cqz", "ituz", "cont", "email", "qsl_via", "image_url", "web", "zip",
|
||||
"source", "fetched_at",
|
||||
}
|
||||
// The lookup cache always lives in the local SQLite database, so SQLite
|
||||
@@ -510,8 +519,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,
|
||||
source, fetched_at)
|
||||
VALUES(?,?,?,?,?,?, ?,?,?,?, ?,?,?,?,?,?,?, ?,?)
|
||||
web, zip, 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),
|
||||
@@ -520,7 +529,7 @@ func (c *Cache) Put(ctx context.Context, r Result) error {
|
||||
nullableFloat(r.Lat), nullableFloat(r.Lon),
|
||||
nullableInt(r.DXCC), nullableInt(r.CQZ), nullableInt(r.ITUZ),
|
||||
nullable(r.Continent), nullable(r.Email), nullable(r.QSLVia),
|
||||
nullable(r.ImageURL),
|
||||
nullable(r.ImageURL), nullable(r.Web), nullable(r.Zip),
|
||||
r.Source, db.NowISO(),
|
||||
)
|
||||
return err
|
||||
|
||||
@@ -4,14 +4,14 @@ import "testing"
|
||||
|
||||
func TestTitleCase(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"NOEL CHENAVARD": "Noel Chenavard",
|
||||
"VETRAZ-MONTHOUX": "Vetraz-Monthoux",
|
||||
"o'brien": "O'Brien",
|
||||
"NOEL CHENAVARD": "Noel Chenavard",
|
||||
"VETRAZ-MONTHOUX": "Vetraz-Monthoux",
|
||||
"o'brien": "O'Brien",
|
||||
"866 ROUTE DES VOIRONS": "866 Route Des Voirons",
|
||||
"PARIS": "Paris",
|
||||
"": "",
|
||||
" saint-étienne ": "Saint-Étienne",
|
||||
"JOHN": "John",
|
||||
"PARIS": "Paris",
|
||||
"": "",
|
||||
" saint-étienne ": "Saint-Étienne",
|
||||
"JOHN": "John",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := titleCase(in); got != want {
|
||||
|
||||
+35
-16
@@ -21,8 +21,14 @@ type QRZ struct {
|
||||
|
||||
HTTP *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
session string
|
||||
// PreferNickname takes QRZ's <nickname> over the composed first+last name
|
||||
// when the operator has published one. It is the name they go BY on the air,
|
||||
// which is what belongs in a log — HamQTH's <nick> is already used that way,
|
||||
// and this brings QRZ into line for operators who want it.
|
||||
PreferNickname bool
|
||||
|
||||
mu sync.Mutex
|
||||
session string
|
||||
loggedAt time.Time
|
||||
}
|
||||
|
||||
@@ -117,7 +123,7 @@ func (q *QRZ) fetch(ctx context.Context, sessionKey, callsign string) (Result, e
|
||||
}
|
||||
r := Result{
|
||||
Callsign: strings.ToUpper(c.Call),
|
||||
Name: joinName(c.FName, c.Name),
|
||||
Name: qrzName(q.PreferNickname, c.Nickname, c.FName, c.Name),
|
||||
QTH: c.Addr2,
|
||||
Address: composeQRZAddress(c.Addr1, c.Addr2, c.Zip, c.Country),
|
||||
State: strings.ToUpper(c.State),
|
||||
@@ -169,6 +175,7 @@ type qrzSession struct {
|
||||
type qrzCallsign struct {
|
||||
Call string `xml:"call"`
|
||||
FName string `xml:"fname"`
|
||||
Nickname string `xml:"nickname"` // the name the operator goes by on the air
|
||||
Name string `xml:"name"`
|
||||
Addr1 string `xml:"addr1"`
|
||||
Addr2 string `xml:"addr2"`
|
||||
@@ -213,19 +220,6 @@ func composeQRZAddress(addr1, addr2, zip, country string) string {
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func joinName(first, last string) string {
|
||||
first = strings.TrimSpace(first)
|
||||
last = strings.TrimSpace(last)
|
||||
switch {
|
||||
case first != "" && last != "":
|
||||
return first + " " + last
|
||||
case first != "":
|
||||
return first
|
||||
default:
|
||||
return last
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmpty(s ...string) string {
|
||||
for _, v := range s {
|
||||
v = strings.TrimSpace(v)
|
||||
@@ -235,3 +229,28 @@ func firstNonEmpty(s ...string) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// qrzName picks what goes in the log's Name field.
|
||||
//
|
||||
// The FIRST name only. A log greets an operator, it does not address an
|
||||
// envelope: "Robert" is what goes out on the air, and "Robert Smith" filled the
|
||||
// field with something no one would ever send. The surname stays available in
|
||||
// the record; it simply is not the name of the contact.
|
||||
//
|
||||
// The nickname is taken ahead of it when the operator asked for that AND QRZ
|
||||
// has one — a blank nickname must never blank the name, which is the whole
|
||||
// reason this is a fallback rather than a swap.
|
||||
//
|
||||
// The surname is the last resort: a record with no first name at all is better
|
||||
// answered with a surname than with nothing.
|
||||
func qrzName(preferNickname bool, nickname, fname, name string) string {
|
||||
if preferNickname {
|
||||
if n := strings.TrimSpace(nickname); n != "" {
|
||||
return n
|
||||
}
|
||||
}
|
||||
if f := strings.TrimSpace(fname); f != "" {
|
||||
return f
|
||||
}
|
||||
return strings.TrimSpace(name)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package lookup
|
||||
|
||||
import "testing"
|
||||
|
||||
// What goes in a log's Name field is the FIRST name. A log greets an operator;
|
||||
// it does not address an envelope. "Robert Smith" filled the field with
|
||||
// something no one would ever send on the air.
|
||||
//
|
||||
// The nickname wins ahead of it when asked for — but a published nickname is
|
||||
// optional, so an empty one must fall through rather than blank the name. That
|
||||
// is the whole difference between a fallback and a swap.
|
||||
func TestQRZName(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
prefer bool
|
||||
nickname, fname, last string
|
||||
want string
|
||||
}{
|
||||
{"first name only, never the surname", false, "Bob", "Robert", "Smith", "Robert"},
|
||||
{"nickname when asked for", true, "Bob", "Robert", "Smith", "Bob"},
|
||||
{"no nickname published falls back to the FIRST name", true, "", "Robert", "Smith", "Robert"},
|
||||
{"a blank nickname is not a nickname", true, " ", "Robert", "Smith", "Robert"},
|
||||
{"option off ignores the nickname", false, "Bob", "Robert", "Smith", "Robert"},
|
||||
{"surname is the last resort, not the default", true, "", "", "Smith", "Smith"},
|
||||
{"nothing published at all", false, "", "", "", ""},
|
||||
{"nickname alone", true, "Bob", "", "", "Bob"},
|
||||
} {
|
||||
if got := qrzName(tc.prefer, tc.nickname, tc.fname, tc.last); got != tc.want {
|
||||
t.Errorf("%s: qrzName(%v, %q, %q, %q) = %q, want %q",
|
||||
tc.name, tc.prefer, tc.nickname, tc.fname, tc.last, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+44
-5
@@ -81,7 +81,16 @@ type Config struct {
|
||||
// OnSpot receives every accepted decode. Called from the MQTT goroutine, so
|
||||
// it must not block: the broker's buffer is what pays for it if it does.
|
||||
OnSpot func(Spot)
|
||||
Logf func(string, ...any)
|
||||
// OnGrid receives the transmitter of EVERY message, before any geographic
|
||||
// filtering, for the callsign-to-locator store. Same goroutine as OnSpot and
|
||||
// the same rule: do not block.
|
||||
OnGrid func(call, grid string)
|
||||
// RxGrids filters at the BROKER: only reports collected by a receiver in one
|
||||
// of these squares are sent at all. Empty keeps the old behaviour, which was
|
||||
// to receive the world and discard it here — measured at 83 messages a second
|
||||
// for the four opening bands, of which about one in a hundred survived.
|
||||
RxGrids []string
|
||||
Logf func(string, ...any)
|
||||
}
|
||||
|
||||
// Watcher owns the MQTT connection and its subscriptions.
|
||||
@@ -115,6 +124,32 @@ func New(cfg Config) *Watcher {
|
||||
return &Watcher{cfg: cfg}
|
||||
}
|
||||
|
||||
// topics builds the subscription list.
|
||||
//
|
||||
// The v2 topic is
|
||||
//
|
||||
// pskr/filter/v2/<band>/<mode>/<tx call>/<rx call>/<tx grid>/<rx grid>/<tx dxcc>/<rx dxcc>
|
||||
//
|
||||
// so the receiver's square is a level the broker can filter on, and a band of
|
||||
// "+" means every band. Filtering by RECEIVER square rather than by receiver
|
||||
// DXCC is deliberate: measured on 20 m, one country ranged from 1.2 messages a
|
||||
// second (OH) to 72.5 (K), because a DXCC can be a continent. By square the
|
||||
// same measurement is 0.2 to 1.2 — the load follows distance, which is what the
|
||||
// feed is actually about, and it is the same for every operator.
|
||||
func (w *Watcher) topics() []string {
|
||||
out := []string{}
|
||||
for _, b := range w.cfg.Bands {
|
||||
if len(w.cfg.RxGrids) == 0 {
|
||||
out = append(out, "pskr/filter/v2/"+b+"/#")
|
||||
continue
|
||||
}
|
||||
for _, g := range w.cfg.RxGrids {
|
||||
out = append(out, "pskr/filter/v2/"+b+"/+/+/+/+/"+strings.ToUpper(g)+"/+/+")
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Start connects and subscribes. Safe to call when already running.
|
||||
func (w *Watcher) Start() error {
|
||||
w.mu.Lock()
|
||||
@@ -143,10 +178,7 @@ func (w *Watcher) Start() error {
|
||||
|
||||
opts.OnConnect = func(c mqtt.Client) {
|
||||
w.cfg.Logf("pskr: connected to %s", w.cfg.Broker)
|
||||
for _, b := range w.cfg.Bands {
|
||||
// Every mode, every pair of stations, on this band. That firehose IS
|
||||
// the point: the detector's job is to find the shape in it.
|
||||
topic := "pskr/filter/v2/" + b + "/#"
|
||||
for _, topic := range w.topics() {
|
||||
if tok := c.Subscribe(topic, 0, w.handle); tok.Wait() && tok.Error() != nil {
|
||||
w.cfg.Logf("pskr: subscribe %s failed: %v", topic, tok.Error())
|
||||
continue
|
||||
@@ -206,6 +238,13 @@ func (w *Watcher) handle(_ mqtt.Client, m mqtt.Message) {
|
||||
return
|
||||
}
|
||||
|
||||
// The locator store takes every transmitter, before any of the geography
|
||||
// below. What it wants is "which square is this callsign in", and that is
|
||||
// true whoever happened to hear the report.
|
||||
if w.cfg.OnGrid != nil {
|
||||
w.cfg.OnGrid(call, grid[:4])
|
||||
}
|
||||
|
||||
// THE RECEIVER HAS TO BE NEAR THE OPERATOR. This is the whole difference
|
||||
// between a useful feed and a world map.
|
||||
//
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package pskr
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The receiver square is a level the BROKER can filter on, which is the whole
|
||||
// point: measured on the live feed, the four opening bands unfiltered are 83
|
||||
// messages a second of which about one in a hundred survives the NearKm test.
|
||||
// One ring of squares is under two a second, and the same for every operator —
|
||||
// where filtering by DXCC ranged from 1.2 (OH) to 72.5 (K) on one band.
|
||||
func TestTopicsFilterOnTheReceiverSquare(t *testing.T) {
|
||||
w := New(Config{Bands: []string{"6m"}, RxGrids: []string{"jn36", "JN37"}})
|
||||
got := w.topics()
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("want one subscription per band × square, got %v", got)
|
||||
}
|
||||
// Level order: band/mode/txcall/rxcall/txgrid/RXGRID/txdxcc/rxdxcc
|
||||
want := "pskr/filter/v2/6m/+/+/+/+/JN36/+/+"
|
||||
if got[0] != want {
|
||||
t.Errorf("topic = %q, want %q", got[0], want)
|
||||
}
|
||||
if !strings.Contains(got[1], "/JN37/") {
|
||||
t.Errorf("square not upper-cased into the topic: %q", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
// With no squares the old behaviour stands: receive the band and decide here.
|
||||
func TestTopicsWithoutSquares(t *testing.T) {
|
||||
w := New(Config{Bands: []string{"10m", "2m"}})
|
||||
got := w.topics()
|
||||
if len(got) != 2 || got[0] != "pskr/filter/v2/10m/#" {
|
||||
t.Errorf("unfiltered topics = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Grid chasing wants every band. "+" is the MQTT single-level wildcard, so one
|
||||
// subscription per square covers the lot instead of one per band per square.
|
||||
func TestTopicsAllBands(t *testing.T) {
|
||||
w := New(Config{Bands: []string{"+"}, RxGrids: []string{"JN36"}})
|
||||
got := w.topics()
|
||||
if len(got) != 1 || got[0] != "pskr/filter/v2/+/+/+/+/+/JN36/+/+" {
|
||||
t.Errorf("all-band topic = %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package qso
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// OrderedIDs against a real SQLite file, with qso_date stored the way the repo
|
||||
// actually writes it.
|
||||
//
|
||||
// The first version scanned that column straight into a time.Time. It compiles,
|
||||
// it reads correctly, and it fails at run time — the column holds a formatted
|
||||
// STRING. The error was swallowed into "the column will be empty", which is
|
||||
// exactly what shipped: a column that was always blank.
|
||||
func TestOrderedIDsAgainstRealSQLite(t *testing.T) {
|
||||
conn, err := sql.Open("sqlite", "file:"+filepath.Join(t.TempDir(), "t.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { conn.Close() })
|
||||
if _, err := conn.Exec(`CREATE TABLE qso (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
callsign TEXT NOT NULL,
|
||||
qso_date TEXT NOT NULL
|
||||
)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Inserted NEWEST first, so the ids run opposite to the dates — what an
|
||||
// imported ADIF produces, and the whole reason the id is not the number.
|
||||
for _, row := range []struct {
|
||||
call string
|
||||
at time.Time
|
||||
}{
|
||||
{"NEWEST", time.Date(2026, 8, 13, 10, 0, 0, 0, time.UTC)},
|
||||
{"MIDDLE", time.Date(2020, 1, 1, 10, 0, 0, 0, time.UTC)},
|
||||
{"OLDEST", time.Date(1999, 5, 5, 10, 0, 0, 0, time.UTC)},
|
||||
} {
|
||||
if _, err := conn.Exec(`INSERT INTO qso (callsign, qso_date) VALUES (?, ?)`,
|
||||
row.call, row.at.UTC().Format(isoMillis)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
ids, newest, err := NewRepo(conn).OrderedIDs(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("OrderedIDs: %v — this is the failure that emptied the column", err)
|
||||
}
|
||||
if len(ids) != 3 {
|
||||
t.Fatalf("got %d ids, want 3", len(ids))
|
||||
}
|
||||
// Rows 1,2,3 were inserted newest→oldest, so chronological order is 3,2,1.
|
||||
if ids[0] != 3 || ids[1] != 2 || ids[2] != 1 {
|
||||
t.Errorf("order = %v, want the oldest contact first (3,2,1)", ids)
|
||||
}
|
||||
if newest.Year() != 2026 {
|
||||
t.Errorf("newest = %v, want the 2026 contact", newest)
|
||||
}
|
||||
}
|
||||
+45
-1
@@ -47,7 +47,15 @@ func MergeNonZero(dst *QSO, src QSO) {
|
||||
// import/export. Pointers are used to distinguish "absent" from "zero".
|
||||
// Anything in ADIF that is not a promoted column lands in Extras.
|
||||
type QSO struct {
|
||||
ID int64 `json:"id"`
|
||||
ID int64 `json:"id"`
|
||||
// Number is the contact's position in the log, oldest = 1.
|
||||
//
|
||||
// NOT a column and NOT the ID: the primary key follows insertion order, so
|
||||
// importing an old ADIF gives the oldest contacts the highest ids. This is a
|
||||
// rank over qso_date, computed by the App layer and stamped on the way out —
|
||||
// which is why it is `json:"-"`-adjacent in spirit: nothing reads or writes
|
||||
// it in SQL.
|
||||
Number int `json:"number,omitempty"`
|
||||
Callsign string `json:"callsign"`
|
||||
QSODate time.Time `json:"qso_date"` // start, UTC
|
||||
QSODateOff time.Time `json:"qso_date_off,omitempty"` // end, UTC
|
||||
@@ -3170,3 +3178,39 @@ func (r *Repo) WorkedGridKeys(ctx context.Context, normMode func(string) string)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// OrderedIDs returns every QSO id in chronological order, oldest first.
|
||||
//
|
||||
// The mirror of ListFiltered's "qso_date DESC, id DESC", so the tie-break is the
|
||||
// same one the grid displays and a contact cannot change number depending on
|
||||
// which way it is read.
|
||||
//
|
||||
// One query, ids only: the caller turns it into a rank once and keeps it in
|
||||
// memory. Asking the database for a row's rank per row would be a correlated
|
||||
// count over the whole log for each of thirty thousand rows.
|
||||
func (r *Repo) OrderedIDs(ctx context.Context) ([]int64, time.Time, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT id, qso_date FROM qso ORDER BY qso_date ASC, id ASC`)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]int64, 0, 4096)
|
||||
// qso_date is stored as a formatted STRING, not a driver date. Scanning it
|
||||
// into a time.Time silently fails on SQLite, which is how this returned an
|
||||
// error and left the whole column empty.
|
||||
var lastDate string
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var at string
|
||||
if err := rows.Scan(&id, &at); err != nil {
|
||||
return nil, time.Time{}, err
|
||||
}
|
||||
out = append(out, id)
|
||||
lastDate = at // ascending, so the last row read is the newest
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, time.Time{}, err
|
||||
}
|
||||
// Parsed once, for the newest row only — the ordering came from SQL.
|
||||
return out, parseTimeLoose(lastDate), nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package spe
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// decodeCSV marks the client connected on EVERY frame it parses, and the poll
|
||||
// goroutine can be mid-read while PowerOff runs. So the frame from a second ago
|
||||
// landed after PowerOff had marked the amp offline and put it straight back —
|
||||
// the console kept showing the amplifier on until the operator pressed OFF a
|
||||
// second time.
|
||||
func TestFrameInFlightDoesNotResurrectASwitchedOffAmp(t *testing.T) {
|
||||
c := &Client{}
|
||||
const frame = ",13K,O,R,A,1,05,1b,0r,M,0000, 0.00, 0.00, 1.3, 0.0, 26,000,000,N,N,"
|
||||
|
||||
// Normal running: a frame marks it connected and reads OPERATE.
|
||||
c.decodeCSV(frame)
|
||||
if !c.GetStatus().Connected || !c.GetStatus().Operate {
|
||||
t.Fatal("a status frame should mark the amp connected")
|
||||
}
|
||||
|
||||
// The operator presses OFF. This is what PowerOff does, in order.
|
||||
c.statusMu.Lock()
|
||||
c.offUntil = time.Now().Add(3 * time.Second)
|
||||
c.statusMu.Unlock()
|
||||
c.setErr(errSwitchedOff{})
|
||||
|
||||
// A frame that was already on the wire arrives now.
|
||||
c.decodeCSV(frame)
|
||||
|
||||
if c.GetStatus().Connected {
|
||||
t.Error("a frame from before the switch-off put the amp back on — this is the double-click on OFF")
|
||||
}
|
||||
}
|
||||
|
||||
// An amp switched back on at its own front panel must reappear without OpsLog
|
||||
// being told, so the suppression has to expire rather than latch.
|
||||
func TestSuppressionExpires(t *testing.T) {
|
||||
c := &Client{}
|
||||
c.statusMu.Lock()
|
||||
c.offUntil = time.Now().Add(-time.Second) // window already past
|
||||
c.statusMu.Unlock()
|
||||
c.setErr(errSwitchedOff{})
|
||||
|
||||
c.decodeCSV(",13K,O,R,A,1,05,1b,0r,M,0000, 0.00, 0.00, 1.3, 0.0, 26,000,000,N,N,")
|
||||
if !c.GetStatus().Connected {
|
||||
t.Error("an amp answering after the window must come back on its own")
|
||||
}
|
||||
}
|
||||
|
||||
type errSwitchedOff struct{}
|
||||
|
||||
func (errSwitchedOff) Error() string { return "switched off" }
|
||||
+56
-2
@@ -91,6 +91,15 @@ type Client struct {
|
||||
statusMu sync.RWMutex
|
||||
status Status
|
||||
lastRaw string // last raw status payload logged (log only on change)
|
||||
// offUntil suppresses status frames that were already in flight when the amp
|
||||
// was switched off. decodeCSV marks the client connected on every frame it
|
||||
// parses, and the poll goroutine can be mid-read while PowerOff runs — so the
|
||||
// frame from a second ago resurrected an amplifier the operator had just
|
||||
// switched off, and the console only caught up when they pressed OFF twice.
|
||||
//
|
||||
// Time-bounded rather than a latch: an amp switched back on at its own front
|
||||
// panel must reappear on its own.
|
||||
offUntil time.Time
|
||||
|
||||
stop chan struct{}
|
||||
running bool
|
||||
@@ -177,6 +186,12 @@ func (c *Client) PowerOn() error {
|
||||
if c.GetStatus().Connected {
|
||||
return nil
|
||||
}
|
||||
// Switching on cancels the suppression window: the operator wants frames
|
||||
// believed again, and waiting out a timer they cannot see would read as the
|
||||
// power-on having failed.
|
||||
c.statusMu.Lock()
|
||||
c.offUntil = time.Time{}
|
||||
c.statusMu.Unlock()
|
||||
// The poll goroutine may still be inside a blocking read on the old handle;
|
||||
// Windows keeps the port "busy" until that read times out (ioTimeout). Retry
|
||||
// the open for a little longer than that.
|
||||
@@ -205,9 +220,17 @@ func (c *Client) PowerOn() error {
|
||||
time.Sleep(wakePulse)
|
||||
return set(true)
|
||||
}
|
||||
if err = pulse(sp.SetRTS); err == nil {
|
||||
err = pulse(sp.SetDTR)
|
||||
// Each line reported separately. Not every USB-serial chip honours the modem
|
||||
// lines, and some refuse them without saying so — on an amplifier that never
|
||||
// wakes, "which of the two failed, or neither" is the whole diagnosis, and a
|
||||
// single combined error cannot give it.
|
||||
rtsErr := pulse(sp.SetRTS)
|
||||
dtrErr := pulse(sp.SetDTR)
|
||||
if err = rtsErr; err == nil {
|
||||
err = dtrErr
|
||||
}
|
||||
applog.Printf("spe: power ON pulse on %s (model=%q transport=%s) — RTS err=%v, DTR err=%v",
|
||||
c.cfg.ComPort, c.GetStatus().Model, c.cfg.Transport, rtsErr, dtrErr)
|
||||
// Booting, the amp re-enumerates its USB interface, which leaves this handle
|
||||
// pointing at a device that no longer exists — the amp came up and OpsLog
|
||||
// still read "offline". Drop it; the poll loop reopens a fresh one as soon as
|
||||
@@ -216,6 +239,26 @@ func (c *Client) PowerOn() error {
|
||||
c.dropLocked()
|
||||
c.mu.Unlock()
|
||||
applog.Printf("spe: power ON — RTS then DTR pulsed on %s (err=%v)", c.cfg.ComPort, err)
|
||||
|
||||
// Say whether it actually woke. "The ON button does nothing" is a report with
|
||||
// three different causes — the pulse was refused, the pulse went out and the
|
||||
// amp ignored it, or the amp came up and the UI missed it — and only the log
|
||||
// can separate them. Watched off the caller's goroutine so the click returns
|
||||
// at once.
|
||||
go func() {
|
||||
deadline := time.Now().Add(15 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
time.Sleep(time.Second)
|
||||
if c.GetStatus().Connected {
|
||||
applog.Printf("spe: power ON — amp answered after %.0fs",
|
||||
15-time.Until(deadline).Seconds())
|
||||
return
|
||||
}
|
||||
}
|
||||
applog.Printf("spe: power ON — no answer within 15s on %s. The pulse was sent; "+
|
||||
"either this amplifier does not wake on RTS/DTR, or the adapter does not drive those lines",
|
||||
c.cfg.ComPort)
|
||||
}()
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -226,6 +269,14 @@ func (c *Client) PowerOn() error {
|
||||
// the poll loop keeps reopening the port with both lines high and that has
|
||||
// never woken it — only the deliberate low→high sequence in PowerOn does.
|
||||
func (c *Client) PowerOff() error {
|
||||
// Close the window BEFORE the key goes out, so a frame already travelling up
|
||||
// the wire cannot land after setErr and undo it. Three seconds covers a poll
|
||||
// interval with room to spare; after that a real answer means the amp is
|
||||
// genuinely alive again, which is what happens if it is switched back on at
|
||||
// its own front panel.
|
||||
c.statusMu.Lock()
|
||||
c.offUntil = time.Now().Add(3 * time.Second)
|
||||
c.statusMu.Unlock()
|
||||
err := c.sendCmd(cmdOff)
|
||||
applog.Printf("spe: power OFF — SWITCH OFF key sent (err=%v)", err)
|
||||
// Drop the link and mark the amp offline at once, exactly as a fresh start
|
||||
@@ -482,6 +533,9 @@ func (c *Client) decodeCSV(payload string) {
|
||||
c.lastRaw = payload
|
||||
applog.Printf("spe: status raw=%q fields=%d", payload, len(f))
|
||||
}
|
||||
if time.Now().Before(c.offUntil) {
|
||||
return // a frame from before the switch-off — the amp is on its way down
|
||||
}
|
||||
c.status.Connected = true
|
||||
c.status.LastError = ""
|
||||
// The real frame carries a leading empty field (it starts with a comma), so the
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hamlog/internal/qso"
|
||||
)
|
||||
|
||||
func at(s string) time.Time {
|
||||
t, err := time.Parse("2006-01-02 15:04", s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// The number is the contact's rank in the WHOLE log, not its position in what
|
||||
// the grid happens to be showing. Ranking inside the result would renumber
|
||||
// every contact the moment a filter is applied, and QSO #1 would change
|
||||
// identity as the operator typed.
|
||||
func TestQSONumberIsGlobalNotPerPage(t *testing.T) {
|
||||
a := &App{}
|
||||
// The log, oldest first: ids are deliberately NOT in date order, which is
|
||||
// exactly what an imported ADIF produces.
|
||||
a.qsoNumbers = map[int64]int{
|
||||
770: 1, // oldest, imported last so it has the highest id
|
||||
101: 2,
|
||||
102: 3,
|
||||
103: 4,
|
||||
}
|
||||
|
||||
// A filtered page holding only two of them, newest first as the grid asks.
|
||||
page := []qso.QSO{{ID: 103}, {ID: 770}}
|
||||
a.stampQSONumbers(page)
|
||||
|
||||
if page[0].Number != 4 {
|
||||
t.Errorf("id 103 numbered %d, want 4", page[0].Number)
|
||||
}
|
||||
if page[1].Number != 1 {
|
||||
t.Errorf("id 770 numbered %d, want 1 — the oldest contact, whatever its id", page[1].Number)
|
||||
}
|
||||
}
|
||||
|
||||
// A contact logged now is the newest, so it takes the next number without
|
||||
// rereading the log — a full scan per QSO would be felt in a contest run.
|
||||
func TestNewQSOTakesTheNextNumber(t *testing.T) {
|
||||
a := &App{}
|
||||
a.qsoNumbers = map[int64]int{1: 1, 2: 2, 3: 3}
|
||||
a.qsoNumMax = at("2026-08-13 10:00")
|
||||
|
||||
a.noteQSONumbered(9, at("2026-08-13 11:00"))
|
||||
if got := a.qsoNumbers[9]; got != 4 {
|
||||
t.Errorf("new QSO numbered %d, want 4", got)
|
||||
}
|
||||
if !a.qsoNumMax.Equal(at("2026-08-13 11:00")) {
|
||||
t.Error("the newest date was not carried forward")
|
||||
}
|
||||
}
|
||||
|
||||
// A contact entered with an OLDER date belongs in the middle of the order.
|
||||
// Appending it would number it last, which is wrong — so the map is dropped and
|
||||
// rebuilt correctly instead.
|
||||
func TestBackdatedQSOForcesARebuild(t *testing.T) {
|
||||
a := &App{}
|
||||
a.qsoNumbers = map[int64]int{1: 1, 2: 2, 3: 3}
|
||||
a.qsoNumMax = at("2026-08-13 10:00")
|
||||
|
||||
a.noteQSONumbered(9, at("2020-01-01 09:00"))
|
||||
if a.qsoNumbers != nil {
|
||||
t.Errorf("a back-dated QSO was appended as the newest: %v", a.qsoNumbers)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing built yet: the lazy build will see the new contact anyway, so this
|
||||
// must not create a one-entry map that then numbers the whole log wrongly.
|
||||
func TestNoteBeforeAnyBuildDoesNothing(t *testing.T) {
|
||||
a := &App{}
|
||||
a.noteQSONumbered(9, at("2026-08-13 11:00"))
|
||||
if a.qsoNumbers != nil {
|
||||
t.Errorf("built a map from a single contact: %v", a.qsoNumbers)
|
||||
}
|
||||
}
|
||||
|
||||
// With no index available the column is simply empty — never wrong.
|
||||
func TestStampWithoutIndexLeavesZero(t *testing.T) {
|
||||
a := &App{} // no qso repo, so the index cannot be built
|
||||
page := []qso.QSO{{ID: 1}, {ID: 2}}
|
||||
a.stampQSONumbers(page)
|
||||
for _, q := range page {
|
||||
if q.Number != 0 {
|
||||
t.Errorf("invented a number without an index: %d", q.Number)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
const (
|
||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||
appVersion = "0.24.7"
|
||||
appVersion = "0.25.0"
|
||||
|
||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||
// to https://us.i.posthog.com for a US project.
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// A CQ in FT8 carries the operator's square and it is where they are NOW.
|
||||
// QRZ and HamQTH describe where they LIVE, which for an expedition is the
|
||||
// wrong side of the planet — RI0FA transmitted QN35 all evening and was logged
|
||||
// with its licence-holder's home square.
|
||||
//
|
||||
// refineGrid is the referee. What it must never do is let a directory value
|
||||
// replace one heard on the air.
|
||||
func TestRefineGridKeepsWhatWasHeardOnAir(t *testing.T) {
|
||||
for _, tc := range []struct{ have, found, want string }{
|
||||
// The expedition case: heard QN35, directory says the home square.
|
||||
{"QN35", "KO85", "QN35"},
|
||||
// Nothing heard: the directory is all there is.
|
||||
{"", "KO85", "KO85"},
|
||||
// A finer square from the SAME field is an upgrade, not a contradiction.
|
||||
{"QN35", "QN35SL", "QN35SL"},
|
||||
// A finer square from a DIFFERENT field is a contradiction — refuse it.
|
||||
{"QN35", "KO85AB", "QN35"},
|
||||
// Case and padding must not decide anything.
|
||||
{" qn35 ", "KO85", "QN35"},
|
||||
{"QN35", "", "QN35"},
|
||||
} {
|
||||
if got := refineGrid(tc.have, tc.found); got != tc.want {
|
||||
t.Errorf("refineGrid(%q, %q) = %q, want %q", tc.have, tc.found, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// And the ordering that makes it work: the decoded grid has to be applied
|
||||
// BEFORE the lookup, or refineGrid has nothing to defend.
|
||||
func TestDecodedGridWinsOverTheDirectory(t *testing.T) {
|
||||
a := &App{}
|
||||
a.rememberDecodeGrid("RI0FA", "QN35", "decode")
|
||||
|
||||
grid := "" // WSJT-X's ADIF carried no GRIDSQUARE, which is the usual case
|
||||
if g := a.lookupDecodeGrid("RI0FA"); g != "" {
|
||||
grid = refineGrid(grid, g)
|
||||
}
|
||||
grid = refineGrid(grid, "KO85") // then the lookup answers with the home square
|
||||
|
||||
if grid != "QN35" {
|
||||
t.Errorf("logged grid = %q, want QN35 — the square the station actually sent", grid)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user