chore: release v0.25.9

This commit is contained in:
2026-08-18 05:09:04 +02:00
parent a81125eab1
commit 9599c3e0b9
18 changed files with 962 additions and 67 deletions
+52
View File
@@ -79,6 +79,9 @@ type Manager struct {
pollEvery time.Duration
cmdDelay time.Duration // pause after each command (some rigs need it)
// freqOffset is the transverter offset in Hz — see SetFreqOffset. Added to
// what the rig reports, taken off what it is told.
freqOffset int64
}
func NewManager(emit func(RigState)) *Manager {
@@ -185,8 +188,39 @@ func (m *Manager) stopLocked() {
}
}
// SetFreqOffset sets the transverter offset: the number of hertz between what
// the RIG is tuned to and where the station is actually on the air.
//
// A 28 MHz IF driving a 144 MHz transverter is an offset of +116 MHz. Everything
// above this layer — the entry form, the band, the log, the cluster, the shared
// CAT servers — then works in real frequencies, and the rig keeps seeing its own.
//
// Zero disables it, which is why it is a plain number and not a flag plus a
// number: "enabled with an offset of nothing" and "disabled" are the same thing
// to everyone downstream.
func (m *Manager) SetFreqOffset(hz int64) {
m.mu.Lock()
m.freqOffset = hz
m.mu.Unlock()
}
// freqOffsetHz reads the offset.
func (m *Manager) freqOffsetHz() int64 {
m.mu.RLock()
defer m.mu.RUnlock()
return m.freqOffset
}
// SetFrequency dispatches a SetFreq call to the CAT goroutine.
//
// The caller speaks in REAL frequencies (a 2 m spot is 144.300), so the offset
// comes back off before the rig hears it. Without this the offset would be a
// display trick: the readout would say 144 and every spot click, band change and
// memory recall would send the rig somewhere 116 MHz away.
func (m *Manager) SetFrequency(hz int64) error {
if off := m.freqOffsetHz(); off != 0 && hz > off {
hz -= off
}
return m.exec(func(b Backend) error { return b.SetFrequency(hz) })
}
@@ -215,6 +249,10 @@ type splitSetter interface {
// band when it was transmitting on the DX's own frequency. A refusal WSJT-X can
// report is worth far more than a success it cannot check.
func (m *Manager) SetSplit(on bool, txHz int64) error {
// Real frequency in, IF frequency out — same as SetFrequency.
if off := m.freqOffsetHz(); off != 0 && txHz > off {
txHz -= off
}
return m.exec(func(b Backend) error {
s, ok := b.(splitSetter)
if !ok {
@@ -744,6 +782,20 @@ func (m *Manager) run(b Backend, stop, done chan struct{}, cmds chan func(), pol
ns.Enabled = true
ns.Backend = b.Name()
ns.UpdatedAt = time.Now()
// Transverter offset: the rig reports its IF, the operator is on the
// real band. Applied BEFORE the band is worked out, or a 28 MHz IF
// behind a 2 m transverter would log every contact on 10 m — and the
// band the backend may already have filled in is the IF's, so it is
// recomputed rather than trusted.
if off := m.freqOffsetHz(); off != 0 {
if ns.FreqHz != 0 {
ns.FreqHz += off
ns.Band = ""
}
if ns.RxFreqHz != 0 {
ns.RxFreqHz += off
}
}
if ns.FreqHz != 0 && ns.Band == "" {
ns.Band = BandFromHz(ns.FreqHz)
}
+39 -4
View File
@@ -195,10 +195,7 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
// operator was looking the call up for, and it came back empty while
// the same lookup without the suffix answered perfectly.
if !saysNothingAboutLocation(call) {
r.Country, r.Continent = "", ""
r.CQZ, r.ITUZ, r.DXCC = 0, 0, 0
r.Lat, r.Lon = 0, 0
r.Grid, r.State, r.County = "", "", ""
clearHomeLocation(&r)
}
fillFromDXCC(&r, dxcc) // entity/zones/lat-lon from the FULL (slashed) call
normalizeNames(&r)
@@ -378,6 +375,17 @@ func titleCase(s string) string {
// the DXCC number since QRZ's value is wrong and we don't have an entity
// → DXCC# table yet.
// Returns true if any field was filled.
// clearHomeLocation drops the fields that say WHERE a callbook record's operator
// lives, and keeps the ones that say WHO they are — name, address, QSL route.
// A portable operator's cards still go to the home address, so that address is
// not wrong; their county is.
func clearHomeLocation(r *Result) {
r.Country, r.Continent = "", ""
r.CQZ, r.ITUZ, r.DXCC = 0, 0, 0
r.Lat, r.Lon = 0, 0
r.Grid, r.State, r.County = "", "", ""
}
func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
if dxcc == nil {
return false
@@ -387,6 +395,33 @@ func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
return false
}
filled := false
// A subdivision belongs to an ENTITY, so a record describing one entity has
// nothing to say about a station operating from another.
//
// TI8/W2RE came back as a Costa Rica contact in Dutchess County, New York, on
// square FN31 — W2RE's home details, from a QRZ page that carries the home
// mailing address as most portable pages do. CNTY is *defined* as a US
// county, so that is not a cosmetic slip: it is a US county award credit
// recorded against a Costa Rica QSO, and a distance and beam heading computed
// from a square 3,600 km from where the station actually is.
//
// The home-call fallback above already clears these, but it only runs when NO
// provider had the slashed form. An operator with a page for their portable
// call never reached it. Cleared here instead, where the operating entity is
// known — which also heals rows already in the cache, since every cache hit
// comes back through here.
//
// Same-entity portables (F4BPO/P, W2RE/2) are untouched: the entities match,
// and there the home details ARE where the operator is.
if dxccNum != 0 && strings.ContainsRune(r.Callsign, '/') && !saysNothingAboutLocation(r.Callsign) {
if home := homeCall(r.Callsign); home != "" && home != r.Callsign {
if homeNum, _, _, _, _, _, _, homeOK := dxcc.Resolve(home); homeOK && homeNum != 0 && homeNum != dxccNum {
clearHomeLocation(r)
filled = true
}
}
}
if country != "" {
r.Country = country
filled = true
+93
View File
@@ -0,0 +1,93 @@
package lookup
import "testing"
// fakeDXCC resolves a handful of calls, enough to stand in for cty.dat.
type fakeDXCC map[string]struct {
num int
country string
cont string
cqz, ituz int
lat, lon float64
}
func (f fakeDXCC) Resolve(call string) (int, string, string, int, int, float64, float64, bool) {
e, ok := f[call]
if !ok {
return 0, "", "", 0, 0, 0, 0, false
}
return e.num, e.country, e.cont, e.cqz, e.ituz, e.lat, e.lon, true
}
func testDXCC() fakeDXCC {
return fakeDXCC{
// Costa Rica, as cty.dat reads the OPERATING call.
"TI8/W2RE": {num: 308, country: "Costa Rica", cont: "NA", cqz: 7, ituz: 11, lat: 9.9, lon: -84.1},
// The home call: United States.
"W2RE": {num: 291, country: "United States", cont: "NA", cqz: 5, ituz: 8, lat: 39.8, lon: -98.5},
// A same-entity portable: still France either way.
"F4BPO/P": {num: 227, country: "France", cont: "EU", cqz: 14, ituz: 27, lat: 46.2, lon: 2.2},
"F4BPO": {num: 227, country: "France", cont: "EU", cqz: 14, ituz: 27, lat: 46.2, lon: 2.2},
}
}
// A callbook record for a portable call routinely carries the operator's HOME
// address — most QRZ pages for a portable call do — and a subdivision belongs to
// an entity. Carrying the home county across an entity change is not cosmetic:
// CNTY is defined as a US county, so TI8/W2RE was coming out as a Costa Rica
// contact credited to Dutchess County, New York, with the distance and beam
// heading taken from a square 3,600 km from where the station actually was.
func TestPortableInAnotherEntityDropsTheHomeSubdivision(t *testing.T) {
r := Result{
Callsign: "TI8/W2RE",
Name: "Raymond", // who they are — kept
QTH: "Poughquag",
Address: "499 Pleasant Ridge Road", // cards still go there — kept
State: "NY",
County: "Dutchess",
Grid: "FN31",
Lat: 41.6, Lon: -73.7,
DXCC: 291,
}
fillFromDXCC(&r, testDXCC())
if r.County != "" {
t.Errorf("county = %q, want empty — a US county on a Costa Rica QSO is a false award credit", r.County)
}
if r.State != "" {
t.Errorf("state = %q, want empty — a subdivision of the entity that is not being worked", r.State)
}
if r.Grid != "" {
t.Errorf("grid = %q, want empty — it is the home square, 3,600 km from the operation", r.Grid)
}
// The entity and its centroid take over, so distance and bearing mean
// something again.
if r.DXCC != 308 || r.Country != "Costa Rica" {
t.Errorf("entity = %d %q, want 308 Costa Rica", r.DXCC, r.Country)
}
if r.Lat != 9.9 || r.Lon != -84.1 {
t.Errorf("lat/lon = %v/%v, want the Costa Rica centroid — the home coordinates must not survive", r.Lat, r.Lon)
}
// Who they are, and where their cards go, is unchanged.
if r.Name != "Raymond" || r.Address != "499 Pleasant Ridge Road" {
t.Errorf("name/address were cleared (%q / %q) — a portable operator's post still reaches home", r.Name, r.Address)
}
}
// The other half of the rule: a portable WITHIN the same entity is at home as
// far as the entity is concerned, and its details must survive untouched.
func TestSameEntityPortableKeepsItsLocation(t *testing.T) {
r := Result{
Callsign: "F4BPO/P",
State: "77", County: "Seine-et-Marne", Grid: "JN18cs",
Lat: 48.8, Lon: 2.4, DXCC: 227,
}
fillFromDXCC(&r, testDXCC())
if r.Grid != "JN18cs" || r.County != "Seine-et-Marne" || r.State != "77" {
t.Errorf("a same-entity portable lost its location: grid=%q county=%q state=%q", r.Grid, r.County, r.State)
}
if r.Lat != 48.8 || r.Lon != 2.4 {
t.Errorf("lat/lon = %v/%v — the precise home position was replaced by the entity centroid", r.Lat, r.Lon)
}
}
+84
View File
@@ -0,0 +1,84 @@
package qso
import (
"context"
"testing"
"time"
)
// LoTW never hands back the submode it was given: every digital contact comes
// back as the mode GROUP, "DATA". Matched on the exact mode string that is
// simply never equal, so an operator downloading their confirmations was told
// their own QSOs were not in their log — reported for FT2 contacts confirmed as
// DATA, and true of FT4 and FT8 alike.
func TestClassKeyMatchesAConfirmationCarryingTheModeGroup(t *testing.T) {
r := openRepo(t)
ctx := context.Background()
when := time.Date(2026, 8, 16, 16, 29, 0, 0, time.UTC)
id, err := r.Add(ctx, QSO{Callsign: "F1NQP", QSODate: when, Band: "20m", Mode: "FT2"})
if err != nil {
t.Fatal(err)
}
minute := when.Format("2006-01-02T15:04")
exact, err := r.DedupeKeyIDs(ctx)
if err != nil {
t.Fatal(err)
}
if _, found := exact[DedupeKey("F1NQP", minute, "20m", "DATA")]; found {
t.Fatal("the exact index matched DATA against FT2 — the test proves nothing")
}
byClass, err := r.DedupeClassKeyIDs(ctx)
if err != nil {
t.Fatal(err)
}
got, found := byClass[DedupeClassKey("F1NQP", minute, "20m", "DATA")]
if !found || got != id {
t.Errorf("class match = (%d, %v), want (%d, true) — the confirmation would be reported as having no local QSO", got, found, id)
}
}
// Two digital contacts with the same station, same band, same minute is barely
// physical — but if it happens, stamping the confirmation on whichever row the
// map happened to keep is a silent error in an award credit. Ambiguity maps to
// 0 so the caller reports it unmatched instead of guessing.
func TestAnAmbiguousClassKeyIsRefusedRatherThanGuessed(t *testing.T) {
r := openRepo(t)
ctx := context.Background()
when := time.Date(2026, 8, 16, 16, 29, 0, 0, time.UTC)
for _, m := range []string{"FT8", "RTTY"} {
if _, err := r.Add(ctx, QSO{Callsign: "F1NQP", QSODate: when, Band: "20m", Mode: m}); err != nil {
t.Fatal(err)
}
}
byClass, err := r.DedupeClassKeyIDs(ctx)
if err != nil {
t.Fatal(err)
}
if got := byClass[DedupeClassKey("F1NQP", when.Format("2006-01-02T15:04"), "20m", "DATA")]; got != 0 {
t.Errorf("ambiguous class key resolved to %d — one of two QSOs was picked at random", got)
}
}
// Phone and CW keep their own classes: a CW confirmation must never land on an
// SSB contact just because the rest of the key agrees.
func TestClassMatchDoesNotCrossPhoneAndCW(t *testing.T) {
r := openRepo(t)
ctx := context.Background()
when := time.Date(2026, 8, 16, 16, 29, 0, 0, time.UTC)
if _, err := r.Add(ctx, QSO{Callsign: "F1NQP", QSODate: when, Band: "20m", Mode: "SSB"}); err != nil {
t.Fatal(err)
}
byClass, err := r.DedupeClassKeyIDs(ctx)
if err != nil {
t.Fatal(err)
}
minute := when.Format("2006-01-02T15:04")
if _, found := byClass[DedupeClassKey("F1NQP", minute, "20m", "CW")]; found {
t.Error("a CW confirmation matched an SSB contact")
}
if _, found := byClass[DedupeClassKey("F1NQP", minute, "20m", "USB")]; !found {
t.Error("USB did not match the SSB contact — the phone sidebands are one class")
}
}
+45
View File
@@ -2691,6 +2691,51 @@ func (r *Repo) DedupeKeyIDs(ctx context.Context) (map[string]int64, error) {
return out, rows.Err()
}
// DedupeClassKey is DedupeKey with the mode collapsed to its CLASS (Phone / CW /
// Digital).
//
// For matching a downloaded confirmation whose mode is not the one you logged.
// LoTW does not hand back the submode it was given: a contact uploaded as FT4,
// FT8 or anything else digital comes back as the mode GROUP, "DATA". Matched on
// the exact string that is simply never equal, and the operator is told their
// own QSO is not in their log — which is how a confirmed contact goes unrecorded.
func DedupeClassKey(callsign, qsoDateMinute, band, mode string) string {
return strings.ToUpper(callsign) + "|" + qsoDateMinute + "|" + strings.ToLower(band) + "|" + modeClass(mode)
}
// DedupeClassKeyIDs is DedupeKeyIDs at mode-CLASS granularity, for the second
// pass when the exact key misses.
//
// A key shared by more than one QSO maps to 0 — AMBIGUOUS, not "pick one". Two
// contacts with the same station, on the same band, in the same minute, in two
// digital modes is barely physical; but if it ever happens, stamping the
// confirmation on whichever row the map happened to keep would be a silent
// error in someone's award credit. Better to report it unmatched.
func (r *Repo) DedupeClassKeyIDs(ctx context.Context) (map[string]int64, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, callsign, substr(qso_date, 1, 16), band, mode
FROM qso`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]int64, 1024)
for rows.Next() {
var id int64
var call, when, band, mode string
if err := rows.Scan(&id, &call, &when, &band, &mode); err != nil {
return nil, err
}
k := DedupeClassKey(call, when, band, mode)
if prev, seen := out[k]; seen && prev != id {
out[k] = 0
continue
}
out[k] = id
}
return out, rows.Err()
}
// matchRef is one local QSO's time + id, for time-window confirmation matching.
type matchRef struct {
when time.Time